├── .dockerignore ├── .editorconfig ├── .eslintignore ├── .eslintrc.json ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── 1-Bug_report.md │ ├── 2-Question.md │ └── 3-Feature_request.md ├── config.yml ├── stale.yml └── workflows │ ├── publish.yml │ └── test.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc.json ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── Dockerfile ├── LICENSE ├── README.md ├── assets ├── Anchor.toml ├── assets.d.ts ├── entitlements.mac.plist ├── icon.icns ├── icon.ico ├── icon.png ├── icon.svg └── icons │ ├── 1024x1024.png │ ├── 128x128.png │ ├── 16x16.png │ ├── 24x24.png │ ├── 256x256.png │ ├── 32x32.png │ ├── 48x48.png │ ├── 512x512.png │ ├── 64x64.png │ └── 96x96.png ├── bin ├── notarize.js └── setup.sh ├── docker └── solana │ └── Dockerfile ├── electron.Dockerfile ├── package.json ├── src ├── __tests__ │ └── App.test.tsx ├── common │ ├── hooks.ts │ └── strings.ts ├── main │ ├── anchor.ts │ ├── const.ts │ ├── ipc │ │ ├── accounts.ts │ │ ├── config.ts │ │ └── docker.ts │ ├── logger.ts │ ├── main.ts │ ├── menu.ts │ ├── preload.js │ ├── transactionLogs.ts │ ├── tsconfig.json │ ├── util.ts │ └── validator.ts ├── renderer │ ├── App.scss │ ├── App.tsx │ ├── auto-imports.d.ts │ ├── common │ │ ├── analytics.ts │ │ ├── globals.ts │ │ └── prettifyPubkey.ts │ ├── components │ │ ├── AccountView.tsx │ │ ├── AirDropSolButton.tsx │ │ ├── CopyIcon.tsx │ │ ├── InlinePK.tsx │ │ ├── LogView.tsx │ │ ├── PinAccountIcon.tsx │ │ ├── ProgramChange.tsx │ │ ├── ProgramChangeView.tsx │ │ ├── TransferSolButton.tsx │ │ ├── WatchAccountButton.tsx │ │ ├── base │ │ │ ├── Chip.tsx │ │ │ ├── EditableText.tsx │ │ │ └── IconButton.tsx │ │ └── tokens │ │ │ ├── ActiveAccordionHeader.tsx │ │ │ ├── CreateNewMintButton.tsx │ │ │ ├── MetaplexMintMetaDataView.tsx │ │ │ ├── MetaplexTokenData.tsx │ │ │ ├── MintInfoView.tsx │ │ │ ├── MintTokenToButton.tsx │ │ │ ├── TokensListView.tsx │ │ │ └── TransferTokenButton.tsx │ ├── data │ │ ├── Config │ │ │ └── configState.ts │ │ ├── SelectedAccountsList │ │ │ └── selectedAccountsState.ts │ │ ├── ValidatorNetwork │ │ │ ├── ValidatorNetwork.tsx │ │ │ └── validatorNetworkState.ts │ │ ├── accounts │ │ │ ├── account.ts │ │ │ ├── accountInfo.ts │ │ │ ├── accountState.ts │ │ │ ├── getAccount.ts │ │ │ └── programChanges.ts │ │ └── localstorage.ts │ ├── hooks.ts │ ├── index.css │ ├── index.html │ ├── index.tsx │ ├── nav │ │ ├── Account.tsx │ │ ├── Anchor.tsx │ │ ├── TokenPage.tsx │ │ ├── Validator.tsx │ │ └── ValidatorNetworkInfo.tsx │ ├── public │ │ └── themes │ │ │ └── vantablack.css │ ├── store.ts │ ├── tsconfig.json │ ├── vite.config.ts │ ├── vitest.config.ts │ ├── wallet-adapter │ │ ├── electronAppStorage.ts │ │ ├── localstorage.ts │ │ └── web3.ts │ └── windi.config.ts └── types │ ├── hexdump-nodejs.d.ts │ └── types.ts └── tsconfig.json /.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | # Logs 3 | logs 4 | *.log 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Coverage directory used by tools like istanbul 12 | coverage 13 | .eslintcache 14 | 15 | # Dependency directory 16 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 17 | node_modules 18 | 19 | # OSX 20 | .DS_Store 21 | 22 | release/app/dist 23 | release/build 24 | 25 | .idea 26 | npm-debug.log.* 27 | *.css.d.ts 28 | *.sass.d.ts 29 | *.scss.d.ts 30 | .vscode 31 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | src/renderer/auto-imports.d.ts 2 | 3 | # Logs 4 | logs 5 | *.log 6 | 7 | # built package 8 | release 9 | 10 | # Runtime data 11 | pids 12 | *.pid 13 | *.seed 14 | 15 | # Coverage directory used by tools like istanbul 16 | coverage 17 | .eslintcache 18 | 19 | # Dependency directory 20 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 21 | node_modules 22 | 23 | 24 | # OSX 25 | .DS_Store 26 | 27 | release/app/dist 28 | release/build 29 | 30 | .idea 31 | npm-debug.log.* 32 | *.css.d.ts 33 | *.sass.d.ts 34 | *.scss.d.ts 35 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "import/no-extraneous-dependencies": "off", 4 | "react/react-in-jsx-scope": "off", 5 | "import/no-dynamic-require": "off", 6 | "react/jsx-props-no-spreading": "off", 7 | "jsx-a11y/no-noninteractive-element-interactions": "off", 8 | "jsx-a11y/click-events-have-key-events": "off", 9 | "jsx-a11y/no-static-element-interactions": "off", 10 | "global-require": "off", 11 | "jest/no-standalone-expect": "off", 12 | "react/jsx-no-undef": "off", 13 | "react/prop-types": "off", 14 | "import/extensions": "off" 15 | }, 16 | "extends": ["erb"], 17 | "parser": "@typescript-eslint/parser", 18 | "parserOptions": { 19 | "ecmaVersion": 2021, 20 | "ecmaFeatures": { 21 | "jsx": true 22 | }, 23 | "sourceType": "module", 24 | "project": "./tsconfig.json", 25 | "tsconfigRootDir": ".", 26 | "createDefaultProgram": true 27 | }, 28 | "settings": { 29 | "import/resolver": { 30 | "typescript": {} 31 | } 32 | }, 33 | "plugins": ["react", "@typescript-eslint"] 34 | } 35 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text eol=lf 2 | *.exe binary 3 | *.png binary 4 | *.jpg binary 5 | *.jpeg binary 6 | *.ico binary 7 | *.icns binary 8 | *.eot binary 9 | *.otf binary 10 | *.ttf binary 11 | *.woff binary 12 | *.woff2 binary 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/1-Bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: You're having technical issues. 🐞 4 | labels: 'bug' 5 | --- 6 | 7 | ## Expected Behavior 8 | 9 | 10 | 11 | ## Current Behavior 12 | 13 | 14 | 15 | ## Steps to Reproduce 16 | 17 | 18 | 19 | 20 | ## Your Environment 21 | 22 | 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/2-Question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question 3 | about: Ask a question.❓ 4 | labels: 'question' 5 | --- 6 | 7 | ## Summary 8 | 9 | 10 | 11 | 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/3-Feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: You want something added to the Workbench. 🎉 4 | labels: 'enhancement' 5 | --- 6 | -------------------------------------------------------------------------------- /.github/config.yml: -------------------------------------------------------------------------------- 1 | requiredHeaders: 2 | - Prerequisites 3 | - Expected Behavior 4 | - Current Behavior 5 | - Possible Solution 6 | - Your Environment 7 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 30 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - pr 8 | - discussion 9 | - e2e 10 | - enhancement 11 | # Comment to post when marking an issue as stale. Set to `false` to disable 12 | markComment: > 13 | This issue has been automatically marked as stale because it has not had 14 | recent activity. It will be closed if no further activity occurs. Thank you 15 | for your contributions. 16 | # Comment to post when closing a stale issue. Set to `false` to disable 17 | closeComment: false 18 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | publish: 9 | runs-on: ${{ matrix.os }} 10 | 11 | strategy: 12 | matrix: 13 | os: [macos-11] 14 | 15 | steps: 16 | - name: Checkout git repo 17 | uses: actions/checkout@v1 18 | 19 | - name: Install Node, NPM and Yarn 20 | uses: actions/setup-node@v1 21 | with: 22 | node-version: 16 23 | 24 | - name: Install dependencies 25 | run: | 26 | npm install && (cd release/app && npm install) 27 | 28 | - name: Publish releases 29 | env: 30 | # These values are used for auto updates signing 31 | APPLE_ID: ${{ secrets.APPLE_ID }} 32 | APPLE_ID_PASS: ${{ secrets.APPLE_ID_PASS }} 33 | CSC_LINK: ${{ secrets.CSC_LINK }} 34 | CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} 35 | # This is used for uploading release assets to github 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | run: | 38 | npm run postinstall 39 | npm run package 40 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | release: 7 | runs-on: ${{ matrix.os }} 8 | 9 | strategy: 10 | matrix: 11 | os: [ubuntu-latest] 12 | 13 | steps: 14 | - name: Check out Git repository 15 | uses: actions/checkout@v1 16 | 17 | - name: Install Node.js, NPM and Yarn 18 | uses: actions/setup-node@v2 19 | with: 20 | node-version: 16 21 | 22 | - name: Install intial npm deps 23 | run: | 24 | npm install -g npm@latest 25 | npm install || exit 0 26 | 27 | - name: Install native modules 28 | run: | 29 | DEBUG=electron-rebuild npm install 30 | 31 | - name: Run tests 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | run: | 35 | npm run package 36 | npm run lint 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Files built during compilation 2 | release 3 | 4 | # ammanrc.js written by start amman validator 5 | .ammanrc.js 6 | 7 | # Logs 8 | logs 9 | *.log 10 | 11 | # Runtime data 12 | pids 13 | *.pid 14 | *.seed 15 | 16 | # Coverage directory used by tools like istanbul 17 | coverage 18 | .eslintcache 19 | 20 | # Dependency directory 21 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 22 | node_modules 23 | 24 | # OSX 25 | .DS_Store 26 | 27 | release/app/dist 28 | release/build 29 | 30 | .idea 31 | npm-debug.log.* 32 | *.css.d.ts 33 | *.sass.d.ts 34 | *.scss.d.ts 35 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules# Ignore artifacts: 2 | build 3 | coverage -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 4, 4 | "semi": false, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Attach to Main", 6 | "request": "attach", 7 | "port": 9223, 8 | "type": "chrome", 9 | "program": "${workspaceFolder}/src/main/main.ts", 10 | "outFiles": ["${workspaceFolder}/release/app/dist/"], 11 | "timeout": 150000 12 | }, 13 | { 14 | "name": "Electron: doesn't work..Main", 15 | "type": "node", 16 | "request": "launch", 17 | "cwd": "${workspaceRoot}", 18 | "args": [], 19 | "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron", 20 | "windows": { 21 | "runtimeExecutable": "${workspaceRoot}\\node_modules\\.bin\\electron.cmd" 22 | }, 23 | // "preLaunchTask": "Start Webpack Dev", 24 | "runtimeArgs": [ 25 | "--remote-debugging-port=9223", 26 | "-r", 27 | "@babel/register", 28 | "./app/main.dev.babel.js" 29 | ], 30 | "env": { 31 | "NODE_ENV": "development", 32 | "HOT": "1", 33 | "HIDE_DEV_TOOLS": "1" 34 | }, 35 | "protocol": "inspector", 36 | "sourceMaps": true, 37 | "outFiles": [], 38 | "timeout": 150000 39 | } 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "dbaeumer.vscode-eslint", 3 | "editor.formatOnSave": true, 4 | "eslint.alwaysShowStatus": true, 5 | "editor.codeActionsOnSave": { 6 | "source.fixAll.eslint": true, 7 | "source.organizeImports": false 8 | }, 9 | "[jsonc]": { 10 | "editor.defaultFormatter": "esbenp.prettier-vscode" 11 | }, 12 | "[typescriptreact]": { 13 | "editor.defaultFormatter": "esbenp.prettier-vscode" 14 | }, 15 | "[json]": { 16 | "editor.defaultFormatter": "vscode.json-language-features" 17 | } 18 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "npm", 6 | "label": "Start Webpack Dev", 7 | "script": "start-renderer-dev", 8 | "options": { 9 | "cwd": "${workspaceFolder}" 10 | }, 11 | "isBackground": true, 12 | "problemMatcher": { 13 | "owner": "custom", 14 | "pattern": { 15 | "regexp": "____________" 16 | }, 17 | "background": { 18 | "activeOnStart": true, 19 | "beginsPattern": "Compiling\\.\\.\\.$", 20 | "endsPattern": "(Compiled successfully|Failed to compile)\\.$" 21 | } 22 | } 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu 2 | 3 | # because tzdata still is stupid 4 | ENV DEBIAN_FRONTEND=noninteractive 5 | 6 | RUN apt-get update \ 7 | && apt-get install -yq curl libudev-dev git build-essential libssl-dev pkg-config 8 | 9 | # make sure the setup.sh script can work 10 | WORKDIR /app/bin/ 11 | COPY bin/setup.sh . 12 | RUN ./setup.sh 13 | 14 | # use the .profile and .bashrc files setup above 15 | SHELL ["/bin/bash", "-c"] 16 | 17 | # node-gyp needs python... 18 | RUN apt-get install -yq python3 python-is-python3 19 | WORKDIR /app/ 20 | # and now the source 21 | #COPY ["package.json", "package-lock.json*", ".erb", "./"] 22 | COPY . . 23 | RUN rm -rf node_modules src/node_modules release/app/node_modules 24 | RUN source /root/.profile && source $HOME/.nvm/nvm.sh \ 25 | && npm install 26 | 27 | # more DEBUG build info 28 | ENV DEBUG=electron-rebuild 29 | 30 | WORKDIR /app/release/app/ 31 | #COPY ["package.json", "package-lock.json*", "./"] 32 | RUN source /root/.profile && source $HOME/.nvm/nvm.sh \ 33 | && npm install 34 | 35 | WORKDIR /app/ 36 | #COPY . . 37 | RUN source /root/.profile && source $HOME/.nvm/nvm.sh \ 38 | && npm run package 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 CryptoWorkbench 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Solana Workbench 2 | ![](https://user-images.githubusercontent.com/28492/167247189-af6778ba-e8ee-4676-a7f1-ec5b9792b8b7.png) 3 | Solana Workbench is your one stop shop for local Solana development. 4 | 5 | Deploy local validators, airdrop tokens, and more with its GUI on OSX and Windows. 6 | Solana development may be like chewing glass today, but we’re on a mission to change 7 | that forever. 8 | 9 | ## Build dependencies 10 | 11 | If you already have Node on your system (we recommend version 17), you can 12 | install the Node deps like so: 13 | 14 | ``` 15 | $ npm install 16 | ``` 17 | 18 | In order to use Anchor functionality, the `anchor` CLI must be 19 | installed. To connect to a local test validator, you can either 20 | run one yourself on the CLI, or use the Docker functionality via 21 | the app. 22 | 23 | Detailed instructions: 24 | 25 | >> NOTE: use `bin/setup.sh` for both Linux and OSX (but don't forget to add XCode cmdline tools for OSX) - it basically does the following 26 | 27 | - [Nvm](https://github.com/nvm-sh/nvm): `curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash` 28 | - Node (latest version): `nvm install 16.15.0` and `nvm use 16.15.0` 29 | - Docker: `curl -o- https://get.docker.com | bash` 30 | - Yarn: `corepack enable` 31 | - Anchor CLI must be available in PATH to use Anchor stuff 32 | - from https://book.anchor-lang.com/chapter_2/installation.html 33 | - `curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh` 34 | - `source $HOME/.cargo/env` 35 | - `sh -c "$(curl -sSfL https://release.solana.com/v1.9.9/install)"` 36 | - `cargo install --git https://github.com/project-serum/anchor avm --locked --force` 37 | - `avm use latest` -- needed on Linux (needs `libudev-dev`) 38 | - Be sure to add `$HOME/.avm/bin` to your PATH to be able to run the installed binaries 39 | 40 | ### Linux 41 | 42 | to build the rust based tools (`solana` and `anchor` cli's), you will also need to install some native build tools and libraries. (See the Dockerfile for more) 43 | 44 | - Docker Desktop, or a working configured Docker setup 45 | 46 | 47 | ``` 48 | sudo apt-get install -yq curl libudev-dev git build-essential libssl-dev pkg-config 49 | ``` 50 | 51 | ### OSX 52 | 53 | - XCode Command Line Tools (if on OSX) 54 | - on OSX some path stuffing around, so solana and anchor binaries are in the path (for development) 55 | - Docker Desktop, or a working configured Docker setup 56 | - Add anchor and solana to your path (edit `~/.zshenv` if you're using `zsh`): 57 | 58 | ``` 59 | . "$HOME/.cargo/env" 60 | 61 | path+=("$HOME/.avm/bin") 62 | path+=("$HOME/.local/share/solana/install/active_release/bin") 63 | 64 | . "$HOME/.nvm/nvm.sh" 65 | 66 | export PATH 67 | ``` 68 | 69 | ### Windows (native) 70 | 71 | without anchor tooling for now 72 | 73 | - [NVM for Windows](https://github.com/coreybutler/nvm-windows) 74 | - Node (latest version): `nvm install 16.15.0` 75 | - as Administrator `nvm use 16.15.0` 76 | - Yarn: `corepack enable` 77 | - `npm install` 78 | - Docker Desktop, or a working configured Docker setup 79 | 80 | 81 | ## Run 82 | 83 | to run: 84 | 85 | ``` 86 | $ npm run dev 87 | ``` 88 | 89 | Now you're working with Workbench! 90 | 91 | ## Development 92 | 93 | The project is currently in a migratory phase from Bootstrap to Tailwind. Do not write new code using Bootstrap layouting. Instead, opt for using Tailwind's 94 | atomic CSS system instead. The goal is to eventually be able to fully remove bootstrap from the codebase. 95 | 96 | ## Building A Release 97 | 98 | On each platform (OSX, Windows, Linux), run: 99 | 100 | ``` 101 | git clone https://github.com/workbenchapp/solana-workbench new-release-dir 102 | cd new-release-dir 103 | npm install 104 | npm run package 105 | ``` 106 | 107 | To sign and notarize the OSX artifacts: 108 | 109 | 1. You must have the correct certificates from developer.apple.com installed on the build computer. 110 | 2. Signing will occur automatically during `npm run package`. 111 | 3. Notarization requires three environment variables to be set: 112 | 1. `APPLE_NOTARIZATION=1` -- Indicate that the builds should be notarized 113 | 2. `APPLE_ID` -- The email address associated with the developer Apple account 114 | 3. `APPLE_ID_PASS` -- The [app specific password](https://support.apple.com/en-us/HT204397) for the app. This is different from the Apple ID's main password and set in the developer portal. 115 | 116 | >> TODO: Add the signing steps for Windows. 117 | 118 | Then upload binaries and `latest*.yml` files to the Github release. 119 | -------------------------------------------------------------------------------- /assets/Anchor.toml: -------------------------------------------------------------------------------- 1 | # stolen from serum, todo: figure 2 | # out less hacky way to make `anchor` 3 | # happy 4 | anchor_version = "0.12.0" 5 | 6 | [workspace] 7 | members = [ 8 | ".", 9 | ] 10 | 11 | [provider] 12 | cluster = "mainnet" 13 | wallet = "~/.config/solana/id.json" 14 | 15 | [programs.mainnet] 16 | serum_dex = { address = "9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin", path = "./target/deploy/serum_dex.so" } 17 | -------------------------------------------------------------------------------- /assets/assets.d.ts: -------------------------------------------------------------------------------- 1 | type Styles = Record; 2 | 3 | declare module '*.svg' { 4 | const content: string; 5 | export default content; 6 | } 7 | 8 | declare module '*.png' { 9 | const content: string; 10 | export default content; 11 | } 12 | 13 | declare module '*.jpg' { 14 | const content: string; 15 | export default content; 16 | } 17 | 18 | declare module '*.scss' { 19 | const content: Styles; 20 | export default content; 21 | } 22 | 23 | declare module '*.sass' { 24 | const content: Styles; 25 | export default content; 26 | } 27 | 28 | declare module '*.css' { 29 | const content: Styles; 30 | export default content; 31 | } 32 | -------------------------------------------------------------------------------- /assets/entitlements.mac.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.cs.allow-unsigned-executable-memory 6 | 7 | com.apple.security.cs.allow-jit 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /assets/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/workbenchapp/solana-workbench/f991c0a0d4a895ba955fe5320c84381ac01ec9ea/assets/icon.icns -------------------------------------------------------------------------------- /assets/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/workbenchapp/solana-workbench/f991c0a0d4a895ba955fe5320c84381ac01ec9ea/assets/icon.ico -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/workbenchapp/solana-workbench/f991c0a0d4a895ba955fe5320c84381ac01ec9ea/assets/icon.png -------------------------------------------------------------------------------- /assets/icons/1024x1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/workbenchapp/solana-workbench/f991c0a0d4a895ba955fe5320c84381ac01ec9ea/assets/icons/1024x1024.png -------------------------------------------------------------------------------- /assets/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/workbenchapp/solana-workbench/f991c0a0d4a895ba955fe5320c84381ac01ec9ea/assets/icons/128x128.png -------------------------------------------------------------------------------- /assets/icons/16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/workbenchapp/solana-workbench/f991c0a0d4a895ba955fe5320c84381ac01ec9ea/assets/icons/16x16.png -------------------------------------------------------------------------------- /assets/icons/24x24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/workbenchapp/solana-workbench/f991c0a0d4a895ba955fe5320c84381ac01ec9ea/assets/icons/24x24.png -------------------------------------------------------------------------------- /assets/icons/256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/workbenchapp/solana-workbench/f991c0a0d4a895ba955fe5320c84381ac01ec9ea/assets/icons/256x256.png -------------------------------------------------------------------------------- /assets/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/workbenchapp/solana-workbench/f991c0a0d4a895ba955fe5320c84381ac01ec9ea/assets/icons/32x32.png -------------------------------------------------------------------------------- /assets/icons/48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/workbenchapp/solana-workbench/f991c0a0d4a895ba955fe5320c84381ac01ec9ea/assets/icons/48x48.png -------------------------------------------------------------------------------- /assets/icons/512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/workbenchapp/solana-workbench/f991c0a0d4a895ba955fe5320c84381ac01ec9ea/assets/icons/512x512.png -------------------------------------------------------------------------------- /assets/icons/64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/workbenchapp/solana-workbench/f991c0a0d4a895ba955fe5320c84381ac01ec9ea/assets/icons/64x64.png -------------------------------------------------------------------------------- /assets/icons/96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/workbenchapp/solana-workbench/f991c0a0d4a895ba955fe5320c84381ac01ec9ea/assets/icons/96x96.png -------------------------------------------------------------------------------- /bin/notarize.js: -------------------------------------------------------------------------------- 1 | const { notarize } = require('electron-notarize'); 2 | const { build } = require('../package.json'); 3 | 4 | exports.default = async function notarizeMacos(context) { 5 | const { electronPlatformName, appOutDir } = context; 6 | if (electronPlatformName !== 'darwin') { 7 | return; 8 | } 9 | 10 | if (!process.env.APPLE_NOTARIZE) { 11 | console.warn( 12 | 'Skipping notarizing step. APPLE_NOTARIZE environment variable is not set' 13 | ); 14 | return; 15 | } 16 | 17 | if (!('APPLE_ID' in process.env && 'APPLE_ID_PASS' in process.env)) { 18 | console.warn( 19 | 'Skipping notarizing step. APPLE_ID and APPLE_ID_PASS env variables must be set' 20 | ); 21 | return; 22 | } 23 | 24 | const appName = context.packager.appInfo.productFilename; 25 | 26 | console.info('Notarizing Apple DMGs'); 27 | 28 | await notarize({ 29 | appBundleId: build.appId, 30 | appPath: `${appOutDir}/${appName}.app`, 31 | appleId: process.env.APPLE_ID, 32 | appleIdPassword: process.env.APPLE_ID_PASS, 33 | }); 34 | }; 35 | -------------------------------------------------------------------------------- /bin/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [[ $(grep debian /etc/os-release) ]]; then 4 | echo "making sure the required dependencies are installed" 5 | if [[ "$(id -u)" != "0" ]]; then 6 | SUDO="sudo " 7 | fi 8 | ${SUDO} apt-get update 9 | ${SUDO} apt-get install -yq curl libudev-dev git build-essential libssl-dev pkg-config 10 | fi 11 | 12 | # - [Nvm](https://github.com/nvm-sh/nvm): 13 | curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash 14 | export NVM_DIR="$HOME/.nvm" 15 | [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" # This loads nvm 16 | [ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion" # This loads nvm bash_completion 17 | # - Node (latest version): 18 | nvm install v16.15.0 19 | nvm use v16.15.0 20 | 21 | # - Docker: 22 | #curl -o- https://get.docker.com | bash 23 | # - Yarn: 24 | corepack enable 25 | # - Anchor CLI must be available in PATH to use Anchor stuff 26 | # - from https://book.anchor-lang.com/chapter_2/installation.html 27 | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- --default-toolchain stable -y 28 | source $HOME/.cargo/env 29 | sh -c "$(curl -sSfL https://release.solana.com/v1.9.9/install)" 30 | echo 'export PATH="/root/.local/share/solana/install/active_release/bin:$PATH"' >> ~/.bashrc 31 | # needs build-essential libssl-dev pkg-config 32 | # see https://github.com/project-serum/anchor/pull/1558 for `avm use --yes` 33 | cargo install --git https://github.com/project-serum/anchor avm --locked --force 34 | avm install latest 35 | avm use latest 36 | # - `warning: be sure to add `/home/sven/.avm/bin` to your PATH to be able to run the installed binaries` 37 | -------------------------------------------------------------------------------- /docker/solana/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM rust:slim-buster 2 | 3 | RUN rustup toolchain install nightly && rustup default nightly && rustup component add rustfmt 4 | RUN apt-get update && apt-get install -y git pkg-config libudev-dev make libclang-dev clang cmake 5 | ENV SOLANA_VERSION v1.9.20 6 | RUN git clone -b $SOLANA_VERSION --depth 1 https://github.com/solana-labs/solana 7 | WORKDIR solana 8 | RUN --mount=type=cache,target=/usr/local/cargo/registry \ 9 | --mount=type=cache,target=/solana/target/release/build \ 10 | --mount=type=cache,target=/solana/target/release/deps \ 11 | --mount=type=cache,target=/solana/target/release/incremental \ 12 | cargo build --release 13 | 14 | FROM debian:bullseye-slim 15 | 16 | RUN apt-get update && apt-get install -y bzip2 17 | VOLUME ["/var/lib/solana-ledger"] 18 | COPY --from=0 /solana/target/release/* /usr/local/bin 19 | -------------------------------------------------------------------------------- /electron.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3 2 | 3 | COPY . /solana-workbench 4 | WORKDIR /solana-workbench 5 | RUN apt-get update && apt-get install -y curl 6 | RUN curl -fsSL https://deb.nodesource.com/setup_14.x | bash - && apt-get install -y nodejs 7 | ENV DEBUG electron-rebuild 8 | RUN rm -f /usr/bin/python && ln -s /usr/bin/python3 /usr/bin/python 9 | 10 | # Need Typescript, etc. for native extension build to work 11 | RUN npm install 12 | RUN (cd ./release/app npm install) 13 | RUN npm build 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | 2 | 3 | { 4 | "name": "solana-workbench", 5 | "productName": "SolanaWorkbench", 6 | "description": "Solana workbench app for making development on Solana better", 7 | "version": "0.4.0", 8 | "main": "./release/dist/main/main.js", 9 | "scripts": { 10 | "start": "npm run dev", 11 | "dev": "concurrently --kill-others \"npm run start:main\" \"npm run start:renderer\"", 12 | "build": "concurrently \"npm run build:main\" \"npm run build:renderer\"", 13 | "build:main": "tsc -p ./src/main/tsconfig.json", 14 | "build:renderer": "vite build --config ./src/renderer/vite.config.ts", 15 | "start:main": "npm run build:main && cross-env NODE_ENV=development electronmon -r ts-node/register/transpile-only ./src/main/main.ts", 16 | "start:renderer": "vite dev --config ./src/renderer/vite.config.ts", 17 | "package": "rimraf ./release && npm run build && electron-builder -- --publish always --win --mac --linux", 18 | "package-nomac": "rimraf ./release && npm run build && electron-builder -- --publish always --win --linux", 19 | "package:asarless": "npm run build && electron-builder build --config.asar=false", 20 | "lint": "cross-env NODE_ENV=development concurrently \"eslint . --ext .js,.jsx,.ts,.tsx\" \"tsc -p ./src/renderer --noemit\" \"tsc -p ./src/main --noemit\"", 21 | "lint-fix": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx --fix", 22 | "test": "vitest run --dir ./src --config ./src/renderer/vitest.config.ts", 23 | "prepare": "husky install", 24 | "postinstall": "electron-builder install-app-deps" 25 | }, 26 | "browserslist": [ 27 | "last 1 electron version" 28 | ], 29 | "lint-staged": { 30 | "*.{js,jsx,ts,tsx}": [ 31 | "cross-env NODE_ENV=development eslint" 32 | ], 33 | "*.json,.{eslintrc,prettierrc}": [ 34 | "prettier --ignore-path .eslintignore --parser json --write" 35 | ], 36 | "*.{css,scss}": [ 37 | "prettier --ignore-path .eslintignore --single-quote --write" 38 | ], 39 | "*.{html,md,yml}": [ 40 | "prettier --ignore-path .eslintignore --single-quote --write" 41 | ] 42 | }, 43 | "electronmon": { 44 | "patterns": [ 45 | "!src/renderer/**" 46 | ] 47 | }, 48 | "build": { 49 | "productName": "Solana Workbench", 50 | "appId": "org.erb.SolanaWorkbench", 51 | "asar": true, 52 | "asarUnpack": "**\\*.{node,dll}", 53 | "files": [ 54 | "./release/dist/**/*", 55 | "!**/*.d.ts", 56 | "package.json" 57 | ], 58 | "mac": { 59 | "target": { 60 | "target": "default", 61 | "arch": [ 62 | "arm64", 63 | "x64" 64 | ] 65 | }, 66 | "type": "distribution", 67 | "hardenedRuntime": true, 68 | "entitlements": "assets/entitlements.mac.plist", 69 | "entitlementsInherit": "assets/entitlements.mac.plist", 70 | "gatekeeperAssess": false 71 | }, 72 | "dmg": { 73 | "contents": [ 74 | { 75 | "x": 130, 76 | "y": 220 77 | }, 78 | { 79 | "x": 410, 80 | "y": 220, 81 | "type": "link", 82 | "path": "/Applications" 83 | } 84 | ] 85 | }, 86 | "win": { 87 | "target": [ 88 | "nsis" 89 | ] 90 | }, 91 | "linux": { 92 | "target": [ 93 | "AppImage" 94 | ], 95 | "category": "Development" 96 | }, 97 | "directories": { 98 | "buildResources": "assets", 99 | "output": "release/build" 100 | }, 101 | "extraResources": [ 102 | "assets/**/*" 103 | ], 104 | "publish": [ 105 | { 106 | "provider": "github", 107 | "owner": "workbenchapp", 108 | "repo": "solana-workbench" 109 | } 110 | ] 111 | }, 112 | "repository": { 113 | "type": "git", 114 | "url": "git+https://github.com/workbenchapp/solana-workbench" 115 | }, 116 | "author": { 117 | "name": "CryptoWorkbench inc", 118 | "email": "nathan@cryptoworkbench.io", 119 | "url": "https://cryptoworkbench.io" 120 | }, 121 | "contributors": [], 122 | "license": "MIT", 123 | "bugs": { 124 | "url": "https://github.com/workbenchapp/solana-workbench/issues" 125 | }, 126 | "keywords": [ 127 | "electron", 128 | "boilerplate", 129 | "react", 130 | "typescript", 131 | "ts", 132 | "sass", 133 | "hot", 134 | "reload", 135 | "vite" 136 | ], 137 | "homepage": "https://github.com/workbenchapp/solana-workbench", 138 | "devDependencies": { 139 | "@esbuild-plugins/node-globals-polyfill": "^0.1.1", 140 | "@iconify-json/mdi": "^1.1.20", 141 | "@project-serum/anchor": "^0.25.0-beta.1", 142 | "@solana/wallet-adapter-wallets": "^0.15.5", 143 | "@solana/web3.js": "^1.41.3", 144 | "@svgr/core": "^6.2.1", 145 | "@teamsupercell/typings-for-css-modules-loader": "^2.5.1", 146 | "@testing-library/react": "^13.2.0", 147 | "@types/amplitude-js": "^8.0.2", 148 | "@types/dockerode": "^3.3.9", 149 | "@types/dompurify": "^2.3.3", 150 | "@types/enzyme": "^3.10.10", 151 | "@types/history": "^5.0.0", 152 | "@types/logfmt": "^1.2.2", 153 | "@types/node": "^17.0.31", 154 | "@types/prop-types": "^15.7.4", 155 | "@types/react": "^18.0.15", 156 | "@types/react-dom": "^18.0.3", 157 | "@types/react-outside-click-handler": "^1.3.0", 158 | "@types/react-test-renderer": "^18.0.0", 159 | "@types/shelljs": "^0.8.11", 160 | "@types/sqlite3": "^3.1.7", 161 | "@types/underscore": "^1.11.3", 162 | "@types/uuid": "^8.3.3", 163 | "@typescript-eslint/eslint-plugin": "^5.16.0", 164 | "@typescript-eslint/parser": "^5.16.0", 165 | "@typescript-eslint/typescript-estree": "^5.16.0", 166 | "@vitejs/plugin-react": "^1.3.2", 167 | "chalk": "^4.1.2", 168 | "concurrently": "^7.1.0", 169 | "core-js": "^3.20.1", 170 | "cross-env": "^7.0.3", 171 | "css-loader": "^6.5.1", 172 | "detect-port": "^1.3.0", 173 | "electron": "^18.2.0", 174 | "electron-builder": "^23.0.3", 175 | "electron-devtools-installer": "^3.2.0", 176 | "electron-notarize": "^1.1.1", 177 | "electron-rebuild": "^3.2.5", 178 | "electronmon": "^2.0.2", 179 | "enzyme": "^3.11.0", 180 | "enzyme-to-json": "^3.6.2", 181 | "eslint": "^8.15.0", 182 | "eslint-config-airbnb": "^19.0.4", 183 | "eslint-config-airbnb-base": "^15.0.0", 184 | "eslint-config-erb": "^4.0.3", 185 | "eslint-config-prettier": "^8.5.0", 186 | "eslint-import-resolver-typescript": "^2.5.0", 187 | "eslint-plugin-compat": "^4.0.2", 188 | "eslint-plugin-import": "^2.25.4", 189 | "eslint-plugin-jest": "^26.5.3", 190 | "eslint-plugin-jsx-a11y": "^6.5.1", 191 | "eslint-plugin-prettier": "^4.0.0", 192 | "eslint-plugin-promise": "^6.0.0", 193 | "eslint-plugin-react": "^7.29.4", 194 | "eslint-plugin-react-hooks": "^4.3.0", 195 | "file-loader": "^6.2.0", 196 | "husky": "^8.0.0", 197 | "identity-obj-proxy": "^3.0.0", 198 | "jest": "^28.1.1", 199 | "jsdom": "^20.0.0", 200 | "lint-staged": "^12.4.1", 201 | "mini-css-extract-plugin": "^2.4.3", 202 | "opencollective-postinstall": "^2.0.3", 203 | "prettier": "^2.6.2", 204 | "react-refresh": "^0.13.0", 205 | "react-refresh-typescript": "^2.0.2", 206 | "react-test-renderer": "^18.1.0", 207 | "rimraf": "^3.0.2", 208 | "sass": "^1.52.3", 209 | "ts-node": "^10.8.1", 210 | "typescript": "^4.6.2", 211 | "unplugin-auto-import": "^0.8.8", 212 | "unplugin-icons": "^0.14.3", 213 | "url-loader": "^4.1.1", 214 | "vite": "2.9.12", 215 | "vite-plugin-checker": "^0.4.6", 216 | "vite-plugin-environment": "^1.1.1", 217 | "vite-plugin-fonts": "^0.4.0", 218 | "vite-plugin-inline-css-modules": "^0.0.4", 219 | "vite-plugin-windicss": "^1.8.4", 220 | "vitest": "^0.14.2", 221 | "windicss": "^3.5.4" 222 | }, 223 | "dependencies": { 224 | "@fortawesome/fontawesome-svg-core": "^6.1.0", 225 | "@fortawesome/free-regular-svg-icons": "^6.1.1", 226 | "@fortawesome/free-solid-svg-icons": "^6.1.0", 227 | "@fortawesome/react-fontawesome": "^0.1.18", 228 | "@metaplex/js": "^4.12.0", 229 | "@reduxjs/toolkit": "^1.7.2", 230 | "@solana/spl-token": "^0.2.0", 231 | "@solana/wallet-adapter-base": "^0.9.5", 232 | "@solana/wallet-adapter-react": "^0.15.4", 233 | "@solana/wallet-adapter-react-ui": "^0.9.6", 234 | "amplitude-js": "^8.12.0", 235 | "ansi_up": "^5.1.0", 236 | "bip39": "^3.0.4", 237 | "bootstrap": "^5.1.3", 238 | "buffer": "^6.0.3", 239 | "classnames": "^2.3.1", 240 | "dockerode": "^3.3.2", 241 | "dompurify": "^2.3.8", 242 | "electron-cfg": "^1.2.7", 243 | "electron-debug": "^3.2.0", 244 | "electron-log": "^4.4.6", 245 | "electron-promise-ipc": "^2.2.4", 246 | "electron-updater": "^5.0.1", 247 | "hexdump-nodejs": "^0.1.0", 248 | "is-electron": "^2.2.1", 249 | "logfmt": "^1.3.2", 250 | "react": "^18.1.0", 251 | "react-bootstrap": "^2.0.2", 252 | "react-dom": "^18.1.0", 253 | "react-editext": "^4.2.1", 254 | "react-outside-click-handler": "^1.3.0", 255 | "react-query": "^3.39.1", 256 | "react-redux": "^8.0.1", 257 | "react-router": "^6.2.2", 258 | "react-router-dom": "^6.2.2", 259 | "react-split": "^2.0.14", 260 | "react-toastify": "^9.0.1", 261 | "regenerator-runtime": "^0.13.9", 262 | "shelljs": "^0.8.5", 263 | "typescript-lru-cache": "^1.2.3", 264 | "underscore": "^1.13.1", 265 | "victory": "^36.3.2", 266 | "winston": "^3.3.3" 267 | }, 268 | "devEngines": { 269 | "node": ">=16.15.0", 270 | "npm": ">=8.x" 271 | }, 272 | "prettier": { 273 | "overrides": [ 274 | { 275 | "files": [ 276 | ".prettierrc", 277 | ".eslintrc" 278 | ], 279 | "options": { 280 | "parser": "json" 281 | } 282 | } 283 | ], 284 | "singleQuote": true 285 | }, 286 | "husky": { 287 | "hooks": { 288 | "pre-commit": "lint-staged" 289 | } 290 | } 291 | } 292 | -------------------------------------------------------------------------------- /src/__tests__/App.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import { Provider } from 'react-redux'; 3 | import { describe, expect, it } from 'vitest'; 4 | import App from '../renderer/App'; 5 | import store from '../renderer/store'; 6 | 7 | describe('App', () => { 8 | it('should render', () => { 9 | expect( 10 | render( 11 | 12 | 13 | 14 | ) 15 | ).toBeTruthy(); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/common/hooks.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 4 | const useInterval = (callback: any, delay: number) => { 5 | const savedCallback = useRef(() => {}); 6 | 7 | // Remember the latest callback. 8 | useEffect(() => { 9 | savedCallback.current = callback; 10 | }, [callback]); 11 | 12 | // Set up the interval. 13 | useEffect(() => { 14 | const tick = () => { 15 | savedCallback.current(); 16 | }; 17 | if (delay !== null) { 18 | const id = setInterval(tick, delay); 19 | return () => clearInterval(id); 20 | } 21 | return () => {}; 22 | }, [delay]); 23 | }; 24 | 25 | export default useInterval; 26 | -------------------------------------------------------------------------------- /src/common/strings.ts: -------------------------------------------------------------------------------- 1 | import { Net } from '../types/types'; 2 | 3 | const netToURL = (net: Net): string => { 4 | switch (net) { 5 | case Net.Localhost: 6 | return 'http://127.0.0.1:8899'; 7 | case Net.Dev: 8 | return 'https://api.devnet.solana.com'; 9 | case Net.Test: 10 | return 'https://api.testnet.solana.com'; 11 | case Net.MainnetBeta: 12 | return 'https://api.mainnet-beta.solana.com'; 13 | default: 14 | } 15 | return ''; 16 | }; 17 | 18 | const explorerURL = (net: Net, address: string) => { 19 | switch (net) { 20 | case Net.Test: 21 | case Net.Dev: 22 | return `https://explorer.solana.com/address/${address}?cluster=${net}`; 23 | case Net.Localhost: 24 | return `https://explorer.solana.com/address/${address}/ \ 25 | ?cluster=custom&customUrl=${encodeURIComponent(netToURL(net))}`; 26 | default: 27 | return `https://explorer.solana.com/address/${address}`; 28 | } 29 | }; 30 | 31 | const truncateSolAmount = (solAmount: number | undefined) => { 32 | if (solAmount === undefined) { 33 | return ''; 34 | } 35 | if (solAmount > 999) { 36 | return solAmount.toFixed(0); 37 | } 38 | if (solAmount < 0.001) { 39 | return solAmount.toPrecision(6); // This is probably redundant 40 | } 41 | return solAmount.toPrecision(9); 42 | }; 43 | 44 | export { netToURL, explorerURL, truncateSolAmount }; 45 | -------------------------------------------------------------------------------- /src/main/anchor.ts: -------------------------------------------------------------------------------- 1 | import { FetchAnchorIDLRequest } from '../types/types'; 2 | import { execAsync, RESOURCES_PATH } from './const'; 3 | import { logger } from './logger'; 4 | 5 | const fetchAnchorIdl = async (msg: FetchAnchorIDLRequest) => { 6 | // Anchor doesn't seem to accept a flag for where Anchor.toml is (???) 7 | // so we do this for now 8 | try { 9 | const cwd = process.cwd(); 10 | process.chdir(RESOURCES_PATH); 11 | const { stdout } = await execAsync(`anchor idl fetch ${msg.programID}`); 12 | process.chdir(cwd); 13 | return JSON.parse(stdout); 14 | } catch (error) { 15 | logger.error(error); 16 | return { 17 | error, 18 | }; 19 | } 20 | return { 21 | error: 'Error getting Anchor IDL', 22 | }; 23 | }; 24 | 25 | export default fetchAnchorIdl; 26 | -------------------------------------------------------------------------------- /src/main/const.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import os from 'os'; 3 | import fs from 'fs'; 4 | import util from 'util'; 5 | import { exec } from 'child_process'; 6 | import { app } from 'electron'; 7 | 8 | const WORKBENCH_VERSION = '0.4.0'; 9 | const RESOURCES_PATH = app.isPackaged 10 | ? path.join(process.resourcesPath, 'assets') 11 | : path.join(__dirname, '..', '..', 'assets'); 12 | const WORKBENCH_DIR_NAME = '.solana-workbench'; 13 | const WORKBENCH_DIR_PATH = path.join(os.homedir(), WORKBENCH_DIR_NAME); 14 | const KEY_FILE_NAME = 'wbkey.json'; 15 | const KEYPAIR_DIR_PATH = path.join(WORKBENCH_DIR_PATH, 'keys'); 16 | const KEY_PATH = path.join(KEYPAIR_DIR_PATH, KEY_FILE_NAME); 17 | const DB_PATH = path.join(WORKBENCH_DIR_PATH, 'db'); 18 | const ACCOUNTS_DIR_PATH = path.join(DB_PATH, 'accounts'); 19 | const CONFIG_FILE_PATH = path.join(DB_PATH, 'config.json'); 20 | const execAsync = util.promisify(exec); 21 | 22 | if (!fs.existsSync(WORKBENCH_DIR_PATH)) { 23 | fs.mkdirSync(WORKBENCH_DIR_PATH); 24 | fs.mkdirSync(DB_PATH); 25 | fs.mkdirSync(ACCOUNTS_DIR_PATH); 26 | fs.mkdirSync(KEYPAIR_DIR_PATH); 27 | fs.writeFileSync(CONFIG_FILE_PATH, '{}'); 28 | } 29 | 30 | export { 31 | WORKBENCH_VERSION, 32 | RESOURCES_PATH, 33 | KEY_PATH, 34 | WORKBENCH_DIR_PATH, 35 | WORKBENCH_DIR_NAME, 36 | KEYPAIR_DIR_PATH, 37 | KEY_FILE_NAME, 38 | DB_PATH, 39 | ACCOUNTS_DIR_PATH, 40 | CONFIG_FILE_PATH, 41 | execAsync, 42 | }; 43 | -------------------------------------------------------------------------------- /src/main/ipc/accounts.ts: -------------------------------------------------------------------------------- 1 | import cfg from 'electron-cfg'; 2 | import promiseIpc from 'electron-promise-ipc'; 3 | import { IpcMainEvent, IpcRendererEvent } from 'electron'; 4 | 5 | import * as web3 from '@solana/web3.js'; 6 | import * as bip39 from 'bip39'; 7 | 8 | import { NewKeyPairInfo } from '../../types/types'; 9 | 10 | import { logger } from '../logger'; 11 | 12 | async function createNewKeypair(): Promise { 13 | const mnemonic = bip39.generateMnemonic(); 14 | const seed = await bip39.mnemonicToSeed(mnemonic); 15 | const newKeypair = web3.Keypair.fromSeed(seed.slice(0, 32)); 16 | 17 | logger.silly( 18 | `main generated new account${newKeypair.publicKey.toString()} ${JSON.stringify( 19 | newKeypair 20 | )}` 21 | ); 22 | 23 | const val = { 24 | privatekey: newKeypair.secretKey, 25 | mnemonic, 26 | }; 27 | cfg.set(`accounts.${newKeypair.publicKey.toString()}`, val); 28 | 29 | return val; 30 | } 31 | 32 | declare type IpcEvent = IpcRendererEvent & IpcMainEvent; 33 | 34 | // Need to import the file and call a function (from the main process) to get the IPC promise to exist. 35 | export function initAccountPromises() { 36 | // gets written to .\AppData\Roaming\SolanaWorkbench\electron-cfg.json on windows 37 | promiseIpc.on('ACCOUNT-GetAll', (event: IpcEvent | undefined) => { 38 | logger.silly('main: called ACCOUNT-GetAll', event); 39 | const config = cfg.get('accounts'); 40 | if (!config) { 41 | return {}; 42 | } 43 | return config; 44 | }); 45 | // TODO: so the idea is that this == a list of private keys with annotations (like human name...) 46 | // so it could be key: public key, value is a map[string]interface{} with a convention that 'privatekey' contains that in X form... 47 | promiseIpc.on( 48 | 'ACCOUNT-Set', 49 | (key: unknown, val: unknown, event?: IpcEvent | undefined) => { 50 | logger.silly(`main: called ACCOUNT-Set, ${key}, ${val}, ${event}`); 51 | return cfg.set(`accounts.${key}`, val); 52 | } 53 | ); 54 | promiseIpc.on( 55 | 'ACCOUNT-CreateNew', 56 | (event: IpcEvent | undefined): Promise => { 57 | logger.silly(`main: called ACCOUNT-CreateNew, ${event}`); 58 | return createNewKeypair(); 59 | } 60 | ); 61 | } 62 | 63 | export default {}; 64 | -------------------------------------------------------------------------------- /src/main/ipc/config.ts: -------------------------------------------------------------------------------- 1 | import cfg from 'electron-cfg'; 2 | import promiseIpc from 'electron-promise-ipc'; 3 | import type { IpcMainEvent, IpcRendererEvent } from 'electron'; 4 | 5 | import { logger } from '../logger'; 6 | 7 | declare type IpcEvent = IpcRendererEvent & IpcMainEvent; 8 | 9 | // NOTE: using the electron-cfg window size code can reault in the window shrinking every time the app restarts 10 | // Sven has seen it on windows with one 4k screen at 100%, the other at 200% 11 | 12 | // Need to import the file and call a function (from the main process) to get the IPC promise to exist. 13 | export function initConfigPromises() { 14 | logger.info(`Config file at ${cfg.file()}`); 15 | // gets written to .\AppData\Roaming\SolanaWorkbench\electron-cfg.json on windows 16 | promiseIpc.on('CONFIG-GetAll', (event: IpcEvent | undefined) => { 17 | logger.silly('main: called CONFIG-GetAll', event); 18 | const config = cfg.get('config'); 19 | if (!config) { 20 | return {}; 21 | } 22 | return config; 23 | }); 24 | promiseIpc.on( 25 | 'CONFIG-Set', 26 | (key: unknown, val: unknown, event?: IpcEvent | undefined) => { 27 | logger.silly(`main: called CONFIG-Set, ${key}, ${val}, ${event}`); 28 | return cfg.set(`config.${key}`, val); 29 | } 30 | ); 31 | } 32 | 33 | export default {}; 34 | -------------------------------------------------------------------------------- /src/main/logger.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import winston from 'winston'; 3 | import fs from 'fs'; 4 | import logfmt from 'logfmt'; 5 | import { RESOURCES_PATH, WORKBENCH_DIR_PATH, WORKBENCH_VERSION } from './const'; 6 | 7 | const MAX_LOG_FILE_BYTES = 5 * 1028 * 1028; 8 | 9 | // eslint-disable-next-line import/no-mutable-exports 10 | let logger = winston.createLogger({ 11 | transports: [new winston.transports.Console()], 12 | }); 13 | 14 | const LOG_DIR_PATH = path.join(WORKBENCH_DIR_PATH, 'logs'); 15 | const LOG_FILE_PATH = path.join(LOG_DIR_PATH, 'latest.log'); 16 | const LOG_KV_PAD = 50; 17 | 18 | if (!fs.existsSync(LOG_DIR_PATH)) { 19 | fs.mkdirSync(LOG_DIR_PATH); 20 | } 21 | 22 | const initLogging = async () => { 23 | // todo: could do better log rotation, 24 | // but this will do for now to avoid infinite growth 25 | try { 26 | const stat = await fs.promises.stat(LOG_FILE_PATH); 27 | if (stat.size > MAX_LOG_FILE_BYTES) { 28 | await fs.promises.rm(LOG_FILE_PATH); 29 | } 30 | // might get exception if file does not exist, 31 | // but it's expected. 32 | // 33 | // eslint-disable-next-line no-empty 34 | } catch (error) {} 35 | 36 | const logfmtFormat = winston.format.printf((info) => { 37 | const { timestamp } = info.metadata; 38 | delete info.metadata.timestamp; 39 | return `${timestamp} ${info.level.toUpperCase()} ${info.message.padEnd( 40 | LOG_KV_PAD, 41 | ' ' 42 | )}${typeof info.metadata === 'object' && logfmt.stringify(info.metadata)}`; 43 | }); 44 | const loggerConfig: winston.LoggerOptions = { 45 | format: winston.format.combine( 46 | winston.format.timestamp(), 47 | winston.format.metadata(), 48 | logfmtFormat 49 | ), 50 | transports: [ 51 | new winston.transports.File({ 52 | filename: LOG_FILE_PATH, 53 | handleExceptions: true, 54 | }), 55 | ], 56 | }; 57 | if (process.env.NODE_ENV === 'development') { 58 | loggerConfig.transports = [new winston.transports.Console()]; 59 | } 60 | logger = winston.createLogger(loggerConfig); 61 | logger.info('Workbench session begin', { 62 | WORKBENCH_VERSION, 63 | RESOURCES_PATH, 64 | }); 65 | }; 66 | 67 | export { logger, initLogging }; 68 | -------------------------------------------------------------------------------- /src/main/main.ts: -------------------------------------------------------------------------------- 1 | import { app, BrowserWindow, ipcMain, shell, dialog } from 'electron'; 2 | import log from 'electron-log'; 3 | import { autoUpdater } from 'electron-updater'; 4 | import path from 'path'; 5 | import 'regenerator-runtime/runtime'; 6 | import fetchAnchorIdl from './anchor'; 7 | import { RESOURCES_PATH } from './const'; 8 | import { initAccountPromises } from './ipc/accounts'; 9 | import { initConfigPromises } from './ipc/config'; 10 | import { 11 | initDockerPromises, 12 | inspectValidatorContainer, 13 | stopValidatorContainer, 14 | removeValidatorContainer, 15 | } from './ipc/docker'; 16 | import { initLogging, logger } from './logger'; 17 | import MenuBuilder from './menu'; 18 | import { 19 | subscribeTransactionLogs, 20 | unsubscribeTransactionLogs, 21 | } from './transactionLogs'; 22 | import { resolveHtmlPath } from './util'; 23 | import { validatorLogs } from './validator'; 24 | 25 | export default class AppUpdater { 26 | constructor() { 27 | log.transports.file.level = 'info'; 28 | autoUpdater.logger = log; 29 | autoUpdater.checkForUpdatesAndNotify(); 30 | } 31 | } 32 | 33 | let mainWindow: BrowserWindow | null = null; 34 | const MAX_STRING_LOG_LENGTH = 32; 35 | 36 | initConfigPromises(); 37 | initAccountPromises(); 38 | initDockerPromises(); 39 | 40 | ipcMain.on( 41 | 'main', 42 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 43 | async (event: Electron.IpcMainEvent, method: string, msg: any) => { 44 | // logger.info('IPC event', { method, ...msg }); 45 | let res = {}; 46 | try { 47 | switch (method) { 48 | case 'validator-logs': 49 | res = await validatorLogs(msg); 50 | break; 51 | case 'fetch-anchor-idl': 52 | res = await fetchAnchorIdl(msg); 53 | logger.debug(`fetchIDL(${msg}: (${res})`); 54 | break; 55 | case 'subscribe-transaction-logs': 56 | await subscribeTransactionLogs(event, msg); 57 | break; 58 | case 'unsubscribe-transaction-logs': 59 | await unsubscribeTransactionLogs(event, msg); 60 | break; 61 | default: 62 | } 63 | let loggedRes = res; 64 | if (typeof loggedRes === 'string') { 65 | loggedRes = { res: `${loggedRes.slice(0, MAX_STRING_LOG_LENGTH)}...` }; 66 | } 67 | // logger.info('OK', { method, ...loggedRes }); 68 | event.reply('main', { method, res }); 69 | } catch (e) { 70 | const error = e as Error; 71 | const { stack } = error; 72 | logger.error('ERROR', { 73 | method, 74 | name: error.name, 75 | }); 76 | logger.error('Stacktrace:'); 77 | stack?.split('\n').forEach((line) => logger.error(`\t${line}`)); 78 | event.reply('main', { method, error }); 79 | } 80 | } 81 | ); 82 | 83 | if (process.env.NODE_ENV === 'production') { 84 | const sourceMapSupport = require('source-map-support'); 85 | sourceMapSupport.install(); 86 | } 87 | 88 | const isDevelopment = 89 | process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true'; 90 | 91 | if (isDevelopment) { 92 | require('electron-debug')(); 93 | } 94 | 95 | const installExtensions = async () => { 96 | const installer = require('electron-devtools-installer'); 97 | const forceDownload = !!process.env.UPGRADE_EXTENSIONS; 98 | const extensions = ['REACT_DEVELOPER_TOOLS']; 99 | 100 | return installer 101 | .default( 102 | extensions.map((name) => installer[name]), 103 | forceDownload 104 | ) 105 | .catch(log.info); 106 | }; 107 | 108 | const createWindow = async () => { 109 | if (isDevelopment) { 110 | await installExtensions(); 111 | } 112 | await initLogging(); 113 | 114 | const getAssetPath = (...paths: string[]): string => { 115 | return path.join(RESOURCES_PATH, ...paths); 116 | }; 117 | 118 | mainWindow = new BrowserWindow({ 119 | show: false, 120 | width: 1024, 121 | height: 728, 122 | icon: getAssetPath('icon.png'), 123 | webPreferences: { 124 | preload: path.join(__dirname, 'preload.js'), 125 | }, 126 | }); 127 | 128 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 129 | // @ts-ignore 130 | // mainWindow.Buffer = Buffer; 131 | 132 | mainWindow.loadURL(resolveHtmlPath('index.html')); 133 | mainWindow.on('ready-to-show', () => { 134 | if (!mainWindow) { 135 | throw new Error('"mainWindow" is not defined'); 136 | } 137 | if (process.env.START_MINIMIZED) { 138 | mainWindow.minimize(); 139 | } else { 140 | mainWindow.show(); 141 | } 142 | }); 143 | 144 | // eslint-disable-next-line consistent-return 145 | mainWindow.on('close', async function (e: Event) { 146 | e.preventDefault(); 147 | 148 | try { 149 | const containerInspect = await inspectValidatorContainer(); 150 | if (!containerInspect?.State?.Running) return app.exit(0); 151 | } catch (err) { 152 | logger.error(err); 153 | app.exit(); // not doing show will make the window "un-closable" if an error occurs while inspecting 154 | } 155 | 156 | const choice = dialog.showMessageBoxSync(mainWindow as BrowserWindow, { 157 | type: 'question', 158 | buttons: ['Stop', 'Stop & Remove', 'Leave Running', 'Cancel'], 159 | title: 'Just before you leave', 160 | message: 161 | 'What would you like to do to the Solana Validator container before exiting?', 162 | icon: getAssetPath('icon.png'), 163 | }); 164 | switch (choice) { 165 | // Stop 166 | case 0: 167 | await stopValidatorContainer(); 168 | app.exit(0); 169 | break; 170 | // Stop & Delete 171 | case 1: 172 | await stopValidatorContainer(); 173 | await removeValidatorContainer(); 174 | app.exit(0); 175 | break; 176 | // Leave Running 177 | case 2: 178 | // TODO might close multiple window at once. 179 | app.exit(0); 180 | break; 181 | // Cancel 182 | case 3: 183 | break; 184 | default: 185 | } 186 | }); 187 | 188 | mainWindow.on('closed', () => { 189 | mainWindow = null; 190 | }); 191 | 192 | const menuBuilder = new MenuBuilder(mainWindow); 193 | menuBuilder.buildMenu(); 194 | 195 | // Open urls in the user's browser 196 | mainWindow.webContents.setWindowOpenHandler(({ url }) => { 197 | shell.openExternal(url); 198 | return { action: 'deny' }; 199 | }); 200 | 201 | // Remove this if your app does not use auto updates 202 | // eslint-disable-next-line 203 | new AppUpdater(); 204 | }; 205 | 206 | /** 207 | * Add event listeners... 208 | */ 209 | 210 | app.on('window-all-closed', () => { 211 | // Respect the OSX convention of having the application in memory even 212 | // after all windows have been closed 213 | if (process.platform !== 'darwin') { 214 | app.quit(); 215 | } 216 | }); 217 | 218 | app 219 | .whenReady() 220 | // eslint-disable-next-line promise/always-return 221 | .then(() => { 222 | createWindow(); 223 | app.on('activate', () => { 224 | // On macOS it's common to re-create a window in the app when the 225 | // dock icon is clicked and there are no other windows open. 226 | if (mainWindow === null) createWindow(); 227 | }); 228 | }) 229 | .catch(log.catchErrors); 230 | -------------------------------------------------------------------------------- /src/main/menu.ts: -------------------------------------------------------------------------------- 1 | import { 2 | app, 3 | Menu, 4 | shell, 5 | BrowserWindow, 6 | MenuItemConstructorOptions, 7 | } from 'electron'; 8 | 9 | interface DarwinMenuItemConstructorOptions extends MenuItemConstructorOptions { 10 | selector?: string; 11 | submenu?: DarwinMenuItemConstructorOptions[] | Menu; 12 | } 13 | 14 | export default class MenuBuilder { 15 | mainWindow: BrowserWindow; 16 | 17 | constructor(mainWindow: BrowserWindow) { 18 | this.mainWindow = mainWindow; 19 | } 20 | 21 | buildMenu(): Menu { 22 | if ( 23 | process.env.NODE_ENV === 'development' || 24 | process.env.DEBUG_PROD === 'true' 25 | ) { 26 | this.setupDevelopmentEnvironment(); 27 | } 28 | 29 | const template = 30 | process.platform === 'darwin' 31 | ? this.buildDarwinTemplate() 32 | : this.buildDefaultTemplate(); 33 | 34 | const menu = Menu.buildFromTemplate(template); 35 | Menu.setApplicationMenu(menu); 36 | 37 | return menu; 38 | } 39 | 40 | setupDevelopmentEnvironment(): void { 41 | this.mainWindow.webContents.on('context-menu', (_, props) => { 42 | const { x, y } = props; 43 | 44 | Menu.buildFromTemplate([ 45 | { 46 | label: 'Inspect element', 47 | click: () => { 48 | this.mainWindow.webContents.inspectElement(x, y); 49 | }, 50 | }, 51 | ]).popup({ window: this.mainWindow }); 52 | }); 53 | } 54 | 55 | buildDarwinTemplate(): MenuItemConstructorOptions[] { 56 | const subMenuAbout: DarwinMenuItemConstructorOptions = { 57 | label: 'Solana Workbench', 58 | submenu: [ 59 | { 60 | label: 'About Solana Workbench', 61 | selector: 'orderFrontStandardAboutPanel:', 62 | }, 63 | { type: 'separator' }, 64 | { label: 'Services', submenu: [] }, 65 | { type: 'separator' }, 66 | { 67 | label: 'Hide Solana Workbench', 68 | accelerator: 'Command+H', 69 | selector: 'hide:', 70 | }, 71 | { 72 | label: 'Hide Others', 73 | accelerator: 'Command+Shift+H', 74 | selector: 'hideOtherApplications:', 75 | }, 76 | { label: 'Show All', selector: 'unhideAllApplications:' }, 77 | { type: 'separator' }, 78 | { 79 | label: 'Quit', 80 | accelerator: 'Command+Q', 81 | click: () => { 82 | app.quit(); 83 | }, 84 | }, 85 | ], 86 | }; 87 | const subMenuEdit: DarwinMenuItemConstructorOptions = { 88 | label: 'Edit', 89 | submenu: [ 90 | { label: 'Undo', accelerator: 'Command+Z', selector: 'undo:' }, 91 | { label: 'Redo', accelerator: 'Shift+Command+Z', selector: 'redo:' }, 92 | { type: 'separator' }, 93 | { label: 'Cut', accelerator: 'Command+X', selector: 'cut:' }, 94 | { label: 'Copy', accelerator: 'Command+C', selector: 'copy:' }, 95 | { label: 'Paste', accelerator: 'Command+V', selector: 'paste:' }, 96 | { 97 | label: 'Select All', 98 | accelerator: 'Command+A', 99 | selector: 'selectAll:', 100 | }, 101 | ], 102 | }; 103 | const subMenuViewDev: MenuItemConstructorOptions = { 104 | label: 'View', 105 | submenu: [ 106 | { 107 | label: 'Reload', 108 | accelerator: 'Command+R', 109 | click: () => { 110 | this.mainWindow.webContents.reload(); 111 | }, 112 | }, 113 | { 114 | label: 'Toggle Full Screen', 115 | accelerator: 'Ctrl+Command+F', 116 | click: () => { 117 | this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen()); 118 | }, 119 | }, 120 | { 121 | label: 'Toggle Developer Tools', 122 | accelerator: 'Alt+Command+I', 123 | click: () => { 124 | this.mainWindow.webContents.toggleDevTools(); 125 | }, 126 | }, 127 | ], 128 | }; 129 | const subMenuViewProd: MenuItemConstructorOptions = { 130 | label: 'View', 131 | submenu: [ 132 | { 133 | label: 'Toggle Full Screen', 134 | accelerator: 'Ctrl+Command+F', 135 | click: () => { 136 | this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen()); 137 | }, 138 | }, 139 | ], 140 | }; 141 | const subMenuWindow: DarwinMenuItemConstructorOptions = { 142 | label: 'Window', 143 | submenu: [ 144 | { 145 | label: 'Minimize', 146 | accelerator: 'Command+M', 147 | selector: 'performMiniaturize:', 148 | }, 149 | { label: 'Close', accelerator: 'Command+W', selector: 'performClose:' }, 150 | { type: 'separator' }, 151 | { label: 'Bring All to Front', selector: 'arrangeInFront:' }, 152 | ], 153 | }; 154 | const subMenuHelp: MenuItemConstructorOptions = { 155 | label: 'Help', 156 | submenu: [ 157 | { 158 | label: 'Github', 159 | click() { 160 | shell.openExternal( 161 | 'https://github.com/workbenchapp/solana-workbench-releases' 162 | ); 163 | }, 164 | }, 165 | ], 166 | }; 167 | 168 | const subMenuView = 169 | process.env.NODE_ENV === 'development' || 170 | process.env.DEBUG_PROD === 'true' 171 | ? subMenuViewDev 172 | : subMenuViewProd; 173 | 174 | return [subMenuAbout, subMenuEdit, subMenuView, subMenuWindow, subMenuHelp]; 175 | } 176 | 177 | buildDefaultTemplate() { 178 | const templateDefault = [ 179 | { 180 | label: '&File', 181 | submenu: [ 182 | { 183 | label: '&Open', 184 | accelerator: 'Ctrl+O', 185 | }, 186 | { 187 | label: '&Close', 188 | accelerator: 'Ctrl+W', 189 | click: () => { 190 | this.mainWindow.close(); 191 | }, 192 | }, 193 | ], 194 | }, 195 | { 196 | label: '&View', 197 | submenu: 198 | process.env.NODE_ENV === 'development' || 199 | process.env.DEBUG_PROD === 'true' 200 | ? [ 201 | { 202 | label: '&Reload', 203 | accelerator: 'Ctrl+R', 204 | click: () => { 205 | this.mainWindow.webContents.reload(); 206 | }, 207 | }, 208 | { 209 | label: 'Toggle &Full Screen', 210 | accelerator: 'F11', 211 | click: () => { 212 | this.mainWindow.setFullScreen( 213 | !this.mainWindow.isFullScreen() 214 | ); 215 | }, 216 | }, 217 | { 218 | label: 'Toggle &Developer Tools', 219 | accelerator: 'Alt+Ctrl+I', 220 | click: () => { 221 | this.mainWindow.webContents.toggleDevTools(); 222 | }, 223 | }, 224 | ] 225 | : [ 226 | { 227 | label: 'Toggle &Full Screen', 228 | accelerator: 'F11', 229 | click: () => { 230 | this.mainWindow.setFullScreen( 231 | !this.mainWindow.isFullScreen() 232 | ); 233 | }, 234 | }, 235 | ], 236 | }, 237 | { 238 | label: 'Help', 239 | submenu: [ 240 | { 241 | label: 'Github', 242 | click() { 243 | shell.openExternal( 244 | 'https://github.com/workbenchapp/solana-workbench-releases' 245 | ); 246 | }, 247 | }, 248 | ], 249 | }, 250 | ]; 251 | 252 | return templateDefault; 253 | } 254 | } 255 | -------------------------------------------------------------------------------- /src/main/preload.js: -------------------------------------------------------------------------------- 1 | const { contextBridge, ipcRenderer } = require('electron'); 2 | const log = require('electron-log'); 3 | const promiseIpc = require('electron-promise-ipc'); 4 | 5 | if (process.env.LOG_LEVEL) { 6 | log.transports.console.level = process.env.LOG_LEVEL; 7 | log.transports.ipc.level = process.env.LOG_LEVEL; 8 | } else { 9 | log.transports.console.level = 'info'; 10 | log.transports.ipc.level = 'info'; 11 | } 12 | 13 | const send = (method, msg) => { 14 | ipcRenderer.send('main', method, msg); 15 | }; 16 | 17 | contextBridge.exposeInMainWorld('electron', { 18 | log: log.functions, 19 | ipcRenderer: { 20 | validatorState(msg) { 21 | send('validator-state', msg); 22 | }, 23 | validatorLogs(msg) { 24 | send('validator-logs', msg); 25 | }, 26 | fetchAnchorIDL(msg) { 27 | send('fetch-anchor-idl', msg); 28 | }, 29 | closeWindowAction(option) { 30 | send('close-window-actions', option); 31 | }, 32 | on(method, func) { 33 | ipcRenderer.on(method, (event, ...args) => func(...args)); 34 | }, 35 | once(method, func) { 36 | ipcRenderer.once(method, (event, ...args) => func(...args)); 37 | }, 38 | removeListener(method, func) { 39 | ipcRenderer.removeListener(method, func); 40 | }, 41 | removeAllListeners(channel) { 42 | ipcRenderer.removeAllListeners(channel); 43 | }, 44 | }, 45 | }); 46 | 47 | contextBridge.exposeInMainWorld('promiseIpc', { 48 | send: (event, ...args) => promiseIpc.send(event, ...args), 49 | on: (event, listener) => promiseIpc.on(event, listener), 50 | off: (event, listener) => promiseIpc.off(event, listener), 51 | removeAllListeners: (event) => promiseIpc.removeAllListeners(event), 52 | }); 53 | -------------------------------------------------------------------------------- /src/main/transactionLogs.ts: -------------------------------------------------------------------------------- 1 | import * as sol from '@solana/web3.js'; 2 | import Electron from 'electron'; 3 | import { LogSubscriptionMap } from '../types/types'; 4 | import { netToURL } from '../common/strings'; 5 | 6 | const logSubscriptions: LogSubscriptionMap = {}; 7 | const subscribeTransactionLogs = async ( 8 | event: Electron.IpcMainEvent, 9 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 10 | msg: any 11 | ) => { 12 | const solConn = new sol.Connection(netToURL(msg.net)); 13 | const subscriptionID = solConn.onLogs( 14 | 'all', 15 | (logsInfo) => { 16 | event.reply('transaction-logs', logsInfo); 17 | }, 18 | 'processed' 19 | ); 20 | logSubscriptions[msg.net] = { subscriptionID, solConn }; 21 | }; 22 | 23 | const unsubscribeTransactionLogs = async ( 24 | _event: Electron.IpcMainEvent, 25 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 26 | msg: any 27 | ) => { 28 | const sub = logSubscriptions[msg.net]; 29 | await sub.solConn.removeOnLogsListener(sub.subscriptionID); 30 | delete logSubscriptions[msg.net]; 31 | }; 32 | 33 | export { unsubscribeTransactionLogs, subscribeTransactionLogs }; 34 | -------------------------------------------------------------------------------- /src/main/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "paths": { 6 | "@/*": ["../*"] 7 | }, 8 | "outDir": "../../release/dist" 9 | }, 10 | "files": ["main.ts"], 11 | "include": ["../types/**/*", "preload.js"] 12 | } 13 | -------------------------------------------------------------------------------- /src/main/util.ts: -------------------------------------------------------------------------------- 1 | import { URL } from 'url'; 2 | import path from 'path'; 3 | 4 | // eslint-disable-next-line import/no-mutable-exports, import/prefer-default-export 5 | export let resolveHtmlPath: (htmlFileName: string) => string; 6 | 7 | if (process.env.NODE_ENV === 'development') { 8 | const port = process.env.PORT || 1212; 9 | resolveHtmlPath = (htmlFileName: string) => { 10 | const url = new URL(`http://localhost:${port}`); 11 | url.pathname = htmlFileName; 12 | return url.href; 13 | }; 14 | } else { 15 | resolveHtmlPath = (htmlFileName: string) => { 16 | return `file://${path.resolve(__dirname, '../renderer/', htmlFileName)}`; 17 | }; 18 | } 19 | -------------------------------------------------------------------------------- /src/main/validator.ts: -------------------------------------------------------------------------------- 1 | import { ValidatorLogsRequest } from '../types/types'; 2 | 3 | const logArray: string[] = []; 4 | const MAX_DISPLAY_LINES = 30; 5 | 6 | const log = (line: string) => { 7 | logArray.push(line); 8 | if (logArray.length > MAX_DISPLAY_LINES) { 9 | logArray.shift(); 10 | } 11 | }; 12 | 13 | // TODO: keeping this as not PromiseIPC for now - but it would be good to move it into ipc/docker.ts later 14 | const validatorLogs = async (msg: ValidatorLogsRequest) => { 15 | const { filter } = msg; 16 | 17 | if (logArray.length === 0) { 18 | if (filter) { 19 | return [`filter: ${filter}`]; 20 | } 21 | return ['no filter']; 22 | } 23 | 24 | return logArray; 25 | }; 26 | 27 | export { log, validatorLogs }; 28 | -------------------------------------------------------------------------------- /src/renderer/App.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * @NOTE: Prepend a `~` to css file paths that are in your node_modules 3 | * See https://github.com/webpack-contrib/sass-loader#imports 4 | */ 5 | @use 'sass:map'; 6 | 7 | // see https://bootswatch.com/ 8 | @import "bootstrap/scss/functions"; 9 | 10 | // 2. Include any default variable overrides here 11 | 12 | // 3. Include remainder of required Bootstrap stylesheets 13 | @import "bootstrap/scss/variables"; 14 | @import "bootstrap/scss/mixins"; 15 | 16 | // Needed for accordions 17 | @import "bootstrap/scss/transitions"; 18 | 19 | 20 | // 4. Include any optional Bootstrap components as you like 21 | @import "bootstrap/scss/accordion"; 22 | .accordion-header { 23 | margin-bottom: 0; 24 | margin-top: 0; 25 | } 26 | @import "bootstrap/scss/buttons"; 27 | @import "bootstrap/scss/dropdown"; 28 | @import "bootstrap/scss/tooltip"; 29 | @import "bootstrap/scss/popover"; 30 | 31 | @import "bootstrap/scss/modal"; 32 | .modal-content { 33 | color: black; 34 | } 35 | 36 | 37 | // TODO: Pretty hacky. I think
 stuff got mixed up
38 | // with the whole Tailwind-Bootstrap situation. For now, we
39 | // always use  when we need an inline monospace,
40 | // and 
 for a block display.
41 | .pre {
42 |     font-family: "Space Mono", monospace;
43 | }
44 | pre {
45 |     background-color: rgb(24, 29, 37);
46 |     font-family: "Space Mono", monospace;
47 | }
48 | code {
49 |     border-width: 0px !important;
50 |     font-family: "Space Mono", monospace;
51 | }
52 | 
53 | .accordion-flush .accordion-item .accordion-button {
54 |     padding: 2px 15px;
55 | }
56 | 
57 | // Import all of bootstrap, this isn't an exercise in minimisation
58 | // @import "bootstrap/scss/bootstrap";
59 | 


--------------------------------------------------------------------------------
/src/renderer/auto-imports.d.ts:
--------------------------------------------------------------------------------
 1 | // Generated by 'unplugin-auto-import'
 2 | export {}
 3 | declare global {
 4 |   const IconMdiAnchor: typeof import('~icons/mdi/anchor.jsx')['default']
 5 |   const IconMdiBookOpenOutline: typeof import('~icons/mdi/book-open-outline.jsx')['default']
 6 |   const IconMdiBroadcastOff: typeof import('~icons/mdi/broadcast-off.jsx')['default']
 7 |   const IconMdiCheck: typeof import('~icons/mdi/check.jsx')['default']
 8 |   const IconMdiChevronDown: typeof import('~icons/mdi/chevron-down.jsx')['default']
 9 |   const IconMdiCircle: typeof import('~icons/mdi/circle.jsx')['default']
10 |   const IconMdiClose: typeof import('~icons/mdi/close.jsx')['default']
11 |   const IconMdiCoins: typeof import('~icons/mdi/coins.jsx')['default']
12 |   const IconMdiContentCopy: typeof import('~icons/mdi/content-copy.jsx')['default']
13 |   const IconMdiKey: typeof import('~icons/mdi/key.jsx')['default']
14 |   const IconMdiPencil: typeof import('~icons/mdi/pencil.jsx')['default']
15 |   const IconMdiStar: typeof import('~icons/mdi/star.jsx')['default']
16 |   const IconMdiStarOutline: typeof import('~icons/mdi/star-outline.jsx')['default']
17 |   const IconMdiTable: typeof import('~icons/mdi/table.jsx')['default']
18 |   const IconMdiUnfoldMoreHorizontal: typeof import('~icons/mdi/unfold-more-horizontal.jsx')['default']
19 |   const IconMdiVectorTriangle: typeof import('~icons/mdi/vector-triangle.jsx')['default']
20 |   const IconMdiWarning: typeof import('~icons/mdi/warning.jsx')['default']
21 | }
22 | 


--------------------------------------------------------------------------------
/src/renderer/common/analytics.ts:
--------------------------------------------------------------------------------
 1 | import amplitude from 'amplitude-js';
 2 | import { logger } from './globals';
 3 | import store from '../store';
 4 | import { ConfigKey } from '../data/Config/configState';
 5 | 
 6 | const AMPLITUDE_KEY = 'f1cde3642f7e0f483afbb7ac15ae8277';
 7 | const AMPLITUDE_HEARTBEAT_INTERVAL = 3600000;
 8 | 
 9 | amplitude.getInstance().init(AMPLITUDE_KEY);
10 | 
11 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
12 | const analytics = (event: string, metadata: any) => {
13 |   const storedConfig = store.getState().config.values;
14 |   if (storedConfig) {
15 |     if (process.env.NODE_ENV !== 'development') {
16 |       if (
17 |         ConfigKey.AnalyticsEnabled in storedConfig &&
18 |         storedConfig[ConfigKey.AnalyticsEnabled] === 'true'
19 |       ) {
20 |         amplitude.getInstance().logEvent(event, metadata);
21 |       }
22 |     } else {
23 |       logger.info('analytics event', event);
24 |     }
25 |   }
26 | };
27 | 
28 | analytics('openApp', {});
29 | setInterval(() => {
30 |   analytics('heartbeat', {});
31 | }, AMPLITUDE_HEARTBEAT_INTERVAL);
32 | 
33 | export default analytics;
34 | 


--------------------------------------------------------------------------------
/src/renderer/common/globals.ts:
--------------------------------------------------------------------------------
 1 | import * as sol from '@solana/web3.js';
 2 | import { Net, netToURL } from '../data/ValidatorNetwork/validatorNetworkState';
 3 | 
 4 | // eslint-disable-next-line import/prefer-default-export
 5 | export const logger = (() => {
 6 |   return window.electron?.log;
 7 | })();
 8 | 
 9 | // TODO: make this selectable - Return information at the selected commitment level
10 | //      [possible values: processed, confirmed, finalized]
11 | //      cli default seems to be finalized
12 | // The _get_ data commitment level - can cause some mutation API calls to fail (i think due to wallet-adapter / metaplex things)
13 | export const commitmentLevel = 'processed';
14 | 
15 | let solConn: sol.Connection | undefined;
16 | let connNet: Net;
17 | 
18 | export function GetValidatorConnection(net: Net) {
19 |   if (connNet === net) {
20 |     if (solConn) {
21 |       return solConn;
22 |     }
23 |     solConn = undefined;
24 |     connNet = net;
25 |   }
26 |   const cfg: sol.ConnectionConfig = {
27 |     commitment: commitmentLevel,
28 |     disableRetryOnRateLimit: true,
29 |   };
30 |   solConn = new sol.Connection(netToURL(net), cfg);
31 | 
32 |   return solConn;
33 | }
34 | 


--------------------------------------------------------------------------------
/src/renderer/common/prettifyPubkey.ts:
--------------------------------------------------------------------------------
 1 | import { ACCOUNTS_NONE_KEY } from '../data/accounts/accountInfo';
 2 | 
 3 | const prettifyPubkey = (pk = '', formatLength?: number) => {
 4 |   if (pk === null) {
 5 |     // cope with bad data in config
 6 |     return '';
 7 |   }
 8 |   if (pk === ACCOUNTS_NONE_KEY) {
 9 |     // cope with bad data in config
10 |     return '';
11 |   }
12 |   if (!formatLength || formatLength + 2 > pk.length) {
13 |     return pk;
14 |   }
15 |   const partLen = (formatLength - 1) / 2;
16 | 
17 |   return `${pk.slice(0, partLen)}…${pk.slice(pk.length - partLen, pk.length)}`;
18 | };
19 | 
20 | export default prettifyPubkey;
21 | 


--------------------------------------------------------------------------------
/src/renderer/components/AccountView.tsx:
--------------------------------------------------------------------------------
  1 | import { faTerminal } from '@fortawesome/free-solid-svg-icons';
  2 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
  3 | import { useEffect, useState } from 'react';
  4 | import ButtonToolbar from 'react-bootstrap/ButtonToolbar';
  5 | import Container from 'react-bootstrap/Container';
  6 | import { Button } from 'react-bootstrap';
  7 | import {
  8 |   useConnection,
  9 |   useWallet,
 10 |   useAnchorWallet,
 11 | } from '@solana/wallet-adapter-react';
 12 | import { Program, AnchorProvider, setProvider } from '@project-serum/anchor';
 13 | import * as sol from '@solana/web3.js';
 14 | import { useQueryClient } from 'react-query';
 15 | import { GetValidatorConnection, logger } from '../common/globals';
 16 | import { useAppDispatch, useAppSelector } from '../hooks';
 17 | 
 18 | import {
 19 |   setAccountValues,
 20 |   useAccountMeta,
 21 | } from '../data/accounts/accountState';
 22 | import {
 23 |   getHumanName,
 24 |   forceRequestAccount,
 25 |   renderRawData,
 26 |   truncateLamportAmount,
 27 |   useParsedAccount,
 28 | } from '../data/accounts/getAccount';
 29 | import { selectValidatorNetworkState } from '../data/ValidatorNetwork/validatorNetworkState';
 30 | import AirDropSolButton from './AirDropSolButton';
 31 | import EditableText from './base/EditableText';
 32 | import InlinePK from './InlinePK';
 33 | import TransferSolButton from './TransferSolButton';
 34 | 
 35 | import CreateNewMintButton, {
 36 |   ensureAtaFor,
 37 | } from './tokens/CreateNewMintButton';
 38 | 
 39 | import { TokensListView } from './tokens/TokensListView';
 40 | 
 41 | function AccountView(props: { pubKey: string | undefined }) {
 42 |   const { pubKey } = props;
 43 |   const { net } = useAppSelector(selectValidatorNetworkState);
 44 |   const dispatch = useAppDispatch();
 45 |   const accountMeta = useAccountMeta(pubKey);
 46 |   const [humanName, setHumanName] = useState('');
 47 |   const accountPubKey = pubKey ? new sol.PublicKey(pubKey) : undefined;
 48 |   const fromKey = useWallet(); // pay from wallet adapter
 49 |   const { connection } = useConnection();
 50 |   const queryClient = useQueryClient();
 51 | 
 52 |   const { /* loadStatus, */ account /* , error */ } = useParsedAccount(
 53 |     net,
 54 |     pubKey,
 55 |     {}
 56 |   );
 57 | 
 58 |   // ("idle" or "error" or "loading" or "success").
 59 |   // TODO: this can't be here before the query
 60 |   // TODO: there's a better way in query v4 - https://tkdodo.eu/blog/offline-react-query
 61 | 
 62 |   // create dummy keypair wallet if none is selected by user
 63 |   // eslint-disable-next-line react-hooks/exhaustive-deps
 64 |   const wallet = useAnchorWallet() || {
 65 |     signAllTransactions: async (
 66 |       transactions: sol.Transaction[]
 67 |     ): Promise => Promise.resolve(transactions),
 68 |     signTransaction: async (
 69 |       transaction: sol.Transaction
 70 |     ): Promise => Promise.resolve(transaction),
 71 |     publicKey: new sol.Keypair().publicKey,
 72 |   };
 73 | 
 74 |   const [decodedAccountData, setDecodedAccountData] = useState();
 75 | 
 76 |   useEffect(() => {
 77 |     setDecodedAccountData('');
 78 |     const decodeAnchor = async () => {
 79 |       try {
 80 |         if (
 81 |           account?.accountInfo &&
 82 |           !account.accountInfo.owner.equals(sol.SystemProgram.programId) &&
 83 |           wallet
 84 |         ) {
 85 |           // TODO: Why do I have to set this every time
 86 |           setProvider(
 87 |             new AnchorProvider(
 88 |               GetValidatorConnection(net),
 89 |               wallet,
 90 |               AnchorProvider.defaultOptions()
 91 |             )
 92 |           );
 93 |           const info = account.accountInfo;
 94 |           const program = await Program.at(info.owner);
 95 | 
 96 |           program?.idl?.accounts?.forEach((accountType) => {
 97 |             try {
 98 |               const decodedAccount = program.coder.accounts.decode(
 99 |                 accountType.name,
100 |                 info.data
101 |               );
102 |               setDecodedAccountData(JSON.stringify(decodedAccount, null, 2));
103 |             } catch (e) {
104 |               const err = e as Error;
105 |               // TODO: only log when error != invalid discriminator
106 |               if (err.message !== 'Invalid account discriminator') {
107 |                 logger.silly(
108 |                   `Account decode failed err="${e}"  attempted_type=${accountType.name}`
109 |                 );
110 |               }
111 |             }
112 |           });
113 |         }
114 |       } catch (e) {
115 |         logger.error(e);
116 |         setDecodedAccountData(renderRawData(account));
117 |       }
118 |     };
119 |     decodeAnchor();
120 |   }, [account, net, wallet]);
121 | 
122 |   useEffect(() => {
123 |     const alias = getHumanName(accountMeta);
124 |     setHumanName(alias);
125 |   }, [pubKey, accountMeta]);
126 | 
127 |   const handleHumanNameSave = (val: string) => {
128 |     if (!pubKey) {
129 |       return;
130 |     }
131 |     dispatch(
132 |       setAccountValues({
133 |         key: pubKey,
134 |         value: {
135 |           ...accountMeta,
136 |           humanname: val,
137 |         },
138 |       })
139 |     );
140 |   };
141 | 
142 |   // const humanName = getHumanName(accountMeta);
143 |   return (
144 |     
145 |       
146 |         
147 | 148 | 154 | 165 |
166 |
167 | 168 |
169 |
170 |
171 |
172 | 173 | 174 | 175 | 182 | 190 | 191 | 192 | 195 | 198 | 199 | 200 | 203 | 212 | 213 | 214 | 215 | 218 | 223 | 224 | 225 | 228 | 243 | 244 | 245 |
176 |
177 |
178 | Editable Alias 179 |
180 |
181 |
183 | 184 | 188 | 189 |
193 | Address 194 | 196 | {pubKey ? : 'None selected'} 197 |
201 | Assigned Program Id 202 | 204 | {account ? ( 205 | 208 | ) : ( 209 | 'Not on chain' 210 | )} 211 |
216 | SOL 217 | 219 | 220 | {account ? truncateLamportAmount(account) : 0} 221 | 222 |
226 | Executable 227 | 229 | {account?.accountInfo?.executable ? ( 230 |
231 | 235 | Yes 236 |
237 | ) : ( 238 | 239 | No 240 | 241 | )} 242 |
246 |
247 |
248 |
249 |
250 | Data : 251 |
252 |
253 | {decodedAccountData !== '' ? ( 254 |
255 |                   {decodedAccountData}
256 |                 
257 | ) : ( 258 | No Data 259 | )} 260 |
261 |
262 | 263 |
264 |
265 | Token Accounts 266 | {/* this button should only be enabled for accounts that you can create a new mint for... */} 267 | { 276 | if (!accountPubKey) { 277 | return newMint; 278 | } 279 | ensureAtaFor(connection, fromKey, newMint, accountPubKey); // needed as we create the Mintlist using the ATA's the user wallet has ATA's for... 280 | return newMint; 281 | }} 282 | /> 283 |
284 |
285 | 286 |
287 |
288 |
289 |
290 |
291 | ); 292 | } 293 | 294 | export default AccountView; 295 | -------------------------------------------------------------------------------- /src/renderer/components/AirDropSolButton.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { Col, Row } from 'react-bootstrap'; 3 | import Button from 'react-bootstrap/Button'; 4 | import Form from 'react-bootstrap/Form'; 5 | import OverlayTrigger from 'react-bootstrap/OverlayTrigger'; 6 | import { useQueryClient } from 'react-query'; 7 | 8 | import Popover from 'react-bootstrap/Popover'; 9 | import { toast } from 'react-toastify'; 10 | import { logger } from '../common/globals'; 11 | 12 | import { 13 | NetStatus, 14 | selectValidatorNetworkState, 15 | } from '../data/ValidatorNetwork/validatorNetworkState'; 16 | 17 | import { airdropSol } from '../data/accounts/account'; 18 | import { useAppSelector } from '../hooks'; 19 | 20 | function AirDropPopover(props: { pubKey: string | undefined }) { 21 | const { pubKey } = props; 22 | const { net } = useAppSelector(selectValidatorNetworkState); 23 | const queryClient = useQueryClient(); 24 | 25 | let pubKeyVal = pubKey; 26 | if (!pubKeyVal) { 27 | pubKeyVal = 'paste'; 28 | } 29 | 30 | const [sol, setSol] = useState('0.01'); 31 | const [toKey, setToKey] = useState(pubKeyVal); 32 | 33 | useEffect(() => { 34 | if (pubKeyVal) { 35 | setToKey(pubKeyVal); 36 | } 37 | }, [pubKeyVal]); 38 | 39 | return ( 40 | 41 | Airdrop SOL 42 | 43 |
44 | 45 | 46 | SOL 47 | 48 | 49 | setSol(e.target.value)} 54 | /> 55 | 56 | 57 | 58 | 59 | 60 | 61 | To 62 | 63 | 64 | setToKey(e.target.value)} 69 | /> 70 | 71 | 72 | 73 | 74 | 75 | 76 | 95 | 96 | 97 |
98 |
99 |
100 | ); 101 | } 102 | 103 | function AirDropSolButton(props: { pubKey: string | undefined }) { 104 | const { pubKey } = props; 105 | const { status } = useAppSelector(selectValidatorNetworkState); 106 | 107 | return ( 108 | 114 | 121 | 122 | ); 123 | } 124 | 125 | export default AirDropSolButton; 126 | -------------------------------------------------------------------------------- /src/renderer/components/CopyIcon.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { OverlayTrigger, Tooltip } from 'react-bootstrap'; 3 | 4 | function CopyIcon(props: { writeValue: string }) { 5 | const { writeValue } = props; 6 | const [copyTooltipText, setCopyTooltipText] = useState( 7 | 'Copy' 8 | ); 9 | 10 | const renderCopyTooltip = (id: string) => 11 | // eslint-disable-next-line @typescript-eslint/no-explicit-any,func-names 12 | function (ttProps: any) { 13 | return ( 14 | // eslint-disable-next-line react/jsx-props-no-spreading 15 | 16 |
{copyTooltipText}
17 |
18 | ); 19 | }; 20 | 21 | return ( 22 | 27 | { 29 | e.stopPropagation(); 30 | setCopyTooltipText('Copied!'); 31 | navigator.clipboard.writeText(writeValue); 32 | }} 33 | onMouseLeave={( 34 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 35 | _ 36 | ) => window.setTimeout(() => setCopyTooltipText('Copy'), 500)} 37 | className="icon-interactive p-2 hover:bg-contrast/10 rounded-full inline-flex items-center justify-center cursor-pointer" 38 | > 39 | 40 | 41 | 42 | ); 43 | } 44 | 45 | export default CopyIcon; 46 | -------------------------------------------------------------------------------- /src/renderer/components/InlinePK.tsx: -------------------------------------------------------------------------------- 1 | import { faExplosion } from '@fortawesome/free-solid-svg-icons'; 2 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 3 | import { OverlayTrigger, Tooltip } from 'react-bootstrap'; 4 | import React from 'react'; 5 | 6 | import analytics from '../common/analytics'; 7 | import prettifyPubkey from '../common/prettifyPubkey'; 8 | import { useAppSelector } from '../hooks'; 9 | import { 10 | Net, 11 | netToURL, 12 | selectValidatorNetworkState, 13 | } from '../data/ValidatorNetwork/validatorNetworkState'; 14 | 15 | import CopyIcon from './CopyIcon'; 16 | 17 | const explorerURL = (net: Net, address: string) => { 18 | switch (net) { 19 | case Net.Test: 20 | case Net.Dev: 21 | return `https://explorer.solana.com/address/${address}?cluster=${net}`; 22 | case Net.Localhost: 23 | return `https://explorer.solana.com/address/${address}/ \ 24 | ?cluster=custom&customUrl=${encodeURIComponent(netToURL(net))}`; 25 | default: 26 | return `https://explorer.solana.com/address/${address}`; 27 | } 28 | }; 29 | 30 | const renderCopyTooltip = (id: string, text: string) => 31 | // eslint-disable-next-line @typescript-eslint/no-explicit-any,func-names 32 | function (ttProps: any) { 33 | return ( 34 | // eslint-disable-next-line react/jsx-props-no-spreading 35 | 36 |
{text}
37 |
38 | ); 39 | }; 40 | 41 | const InlinePK: React.FC<{ 42 | pk: string | undefined; 43 | className?: string; 44 | formatLength?: number; 45 | }> = ({ pk, className, formatLength }) => { 46 | const { net } = useAppSelector(selectValidatorNetworkState); 47 | 48 | if (!pk) { 49 | return ( 50 | 51 | No onchain account 52 | 53 | ); 54 | } 55 | 56 | return ( 57 | 58 | {prettifyPubkey(pk, formatLength)} 59 | 60 | 61 | {pk !== '' ? ( 62 | 67 | analytics('clickExplorerLink', { net })} 69 | href={explorerURL(net, pk)} 70 | target="_blank" 71 | className="sol-link" 72 | rel="noreferrer" 73 | > 74 | 78 | 79 | 80 | ) : ( 81 | 'No onchain account' 82 | )} 83 | 84 | 85 | ); 86 | }; 87 | 88 | InlinePK.defaultProps = { 89 | className: '', 90 | formatLength: 32, 91 | }; 92 | 93 | export default InlinePK; 94 | -------------------------------------------------------------------------------- /src/renderer/components/LogView.tsx: -------------------------------------------------------------------------------- 1 | import * as sol from '@solana/web3.js'; 2 | import { useEffect, useState } from 'react'; 3 | import { 4 | logger, 5 | commitmentLevel, 6 | GetValidatorConnection, 7 | } from '../common/globals'; 8 | import { 9 | NetStatus, 10 | selectValidatorNetworkState, 11 | } from '../data/ValidatorNetwork/validatorNetworkState'; 12 | import { useAppSelector } from '../hooks'; 13 | 14 | export interface LogSubscriptionMap { 15 | [net: string]: { 16 | subscriptionID: number; 17 | solConn: sol.Connection; 18 | }; 19 | } 20 | 21 | const logSubscriptions: LogSubscriptionMap = {}; 22 | 23 | function LogView() { 24 | const [logs, setLogs] = useState([]); 25 | const { net, status } = useAppSelector(selectValidatorNetworkState); 26 | 27 | useEffect(() => { 28 | setLogs([]); 29 | 30 | if (status !== NetStatus.Running) { 31 | return () => {}; 32 | } 33 | 34 | const solConn = GetValidatorConnection(net); 35 | const subscriptionID = solConn.onLogs( 36 | 'all', 37 | (logsInfo) => { 38 | setLogs((prevLogs: string[]) => { 39 | const newLogs = [ 40 | logsInfo.signature, 41 | logsInfo.err?.toString() || 'Ok', 42 | ...logsInfo.logs.reverse(), 43 | ...prevLogs, 44 | ]; 45 | 46 | // utter pseudo-science -- determine max log lines from window size 47 | const MAX_DISPLAYED_LOG_LINES = (3 * window.innerHeight) / 22; 48 | if (newLogs.length > MAX_DISPLAYED_LOG_LINES) { 49 | return newLogs.slice(0, MAX_DISPLAYED_LOG_LINES); 50 | } 51 | return newLogs; 52 | }); 53 | }, 54 | commitmentLevel 55 | ); 56 | logSubscriptions[net] = { subscriptionID, solConn }; 57 | 58 | return () => { 59 | const sub = logSubscriptions[net]; 60 | if (sub?.solConn) { 61 | sub.solConn 62 | .removeOnLogsListener(sub.subscriptionID) 63 | // eslint-disable-next-line promise/always-return 64 | .then(() => { 65 | delete logSubscriptions[net]; 66 | }) 67 | .catch(logger.info); 68 | } 69 | }; 70 | }, [net, status]); 71 | 72 | return ( 73 |
74 |       {logs.length > 0 ? logs.join('\n') : ''}
75 |     
76 | ); 77 | } 78 | 79 | export default LogView; 80 | -------------------------------------------------------------------------------- /src/renderer/components/PinAccountIcon.tsx: -------------------------------------------------------------------------------- 1 | import { OverlayTrigger, Tooltip } from 'react-bootstrap'; 2 | 3 | function PinAccountIcon(props: { 4 | pinned: boolean; 5 | pinAccount: (pk: string, b: boolean) => void; 6 | pubKey: string; 7 | }) { 8 | const { pinned, pinAccount, pubKey } = props; 9 | 10 | const renderPinTooltip = (id: string) => 11 | // eslint-disable-next-line @typescript-eslint/no-explicit-any,func-names 12 | function (ttProps: any) { 13 | return ( 14 | // eslint-disable-next-line react/jsx-props-no-spreading 15 | 16 |
{pinned ? 'Unpin' : 'Pin'}
17 |
18 | ); 19 | }; 20 | 21 | return ( 22 | 27 | { 29 | e.stopPropagation(); 30 | pinAccount(pubKey, pinned); 31 | }} 32 | className="icon-interactive p-2 hover:bg-contrast/10 rounded-full inline-flex items-center justify-center cursor-pointer" 33 | > 34 | {pinned ? : } 35 | 36 | 37 | ); 38 | } 39 | 40 | export default PinAccountIcon; 41 | -------------------------------------------------------------------------------- /src/renderer/components/ProgramChange.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from 'react'; 2 | import { setSelected } from '../data/SelectedAccountsList/selectedAccountsState'; 3 | import { AccountInfo } from '../data/accounts/accountInfo'; 4 | import { useAccountMeta } from '../data/accounts/accountState'; 5 | import { 6 | getAccount, 7 | truncateLamportAmount, 8 | truncateSolAmount, 9 | } from '../data/accounts/getAccount'; 10 | import { 11 | Net, 12 | NetStatus, 13 | selectValidatorNetworkState, 14 | } from '../data/ValidatorNetwork/validatorNetworkState'; 15 | import { useAppDispatch, useAppSelector, useInterval } from '../hooks'; 16 | import InlinePK from './InlinePK'; 17 | import PinAccountIcon from './PinAccountIcon'; 18 | 19 | export function ProgramChange(props: { 20 | net: Net; 21 | pubKey: string; 22 | pinned: boolean; 23 | pinAccount: (pk: string, b: boolean) => void; 24 | selected: boolean; 25 | }) { 26 | const dispatch = useAppDispatch(); 27 | const { pubKey, selected, net, pinned, pinAccount } = props; 28 | const [change, setChangeInfo] = useState(undefined); 29 | const { status } = useAppSelector(selectValidatorNetworkState); 30 | const accountMeta = useAccountMeta(pubKey); 31 | 32 | const updateAccount = useCallback(() => { 33 | if (status !== NetStatus.Running) { 34 | return; 35 | } 36 | if (!pubKey) { 37 | // setChangeInfo(undefined); 38 | return; 39 | } 40 | const update = getAccount(net, pubKey); 41 | if (!update) { 42 | return; 43 | } 44 | setChangeInfo(update); 45 | }, [net, status, pubKey]); 46 | useEffect(updateAccount, [updateAccount]); 47 | useInterval(updateAccount, 666); 48 | 49 | if (!change) { 50 | return null; 51 | } 52 | 53 | const showCount = change?.count || 0; 54 | const showSOL = change 55 | ? truncateLamportAmount(change) 56 | : `no account on ${net}`; 57 | const showChange = change ? truncateSolAmount(change.maxDelta) : 0; 58 | 59 | return ( 60 | dispatch(setSelected(pubKey))} 62 | className={`transition cursor-pointer duration-50 bg-opacity-20 hover:bg-opacity-30 hover:bg-primary-light ${ 63 | selected ? 'bg-primary-light' : '' 64 | }`} 65 | > 66 | 67 | 68 | 73 | 74 | 75 | 76 | 81 | {accountMeta?.privatekey ? : ''} 82 | 83 | 84 | 85 | {showChange} 86 | 87 | 88 | 89 | 90 | {showSOL} 91 | 92 | 93 | 94 | 95 | {showCount} 96 | 97 | 98 | 99 | ); 100 | } 101 | 102 | export default ProgramChange; 103 | -------------------------------------------------------------------------------- /src/renderer/components/TransferSolButton.tsx: -------------------------------------------------------------------------------- 1 | import { useConnection, useWallet } from '@solana/wallet-adapter-react'; 2 | import { useEffect, useState } from 'react'; 3 | import { Col, Row } from 'react-bootstrap'; 4 | import Button from 'react-bootstrap/Button'; 5 | import Form from 'react-bootstrap/Form'; 6 | import OverlayTrigger from 'react-bootstrap/OverlayTrigger'; 7 | import Popover from 'react-bootstrap/Popover'; 8 | import { toast } from 'react-toastify'; 9 | import { useQueryClient } from 'react-query'; 10 | import { useAppSelector } from '../hooks'; 11 | 12 | import CopyIcon from './CopyIcon'; 13 | import prettifyPubkey from '../common/prettifyPubkey'; 14 | 15 | import { sendSolFromSelectedWallet } from '../data/accounts/account'; 16 | import { 17 | NetStatus, 18 | selectValidatorNetworkState, 19 | } from '../data/ValidatorNetwork/validatorNetworkState'; 20 | import { logger } from '../common/globals'; 21 | 22 | const PK_FORMAT_LENGTH = 24; 23 | 24 | function TransferSolPopover(props: { 25 | pubKey: string | undefined; 26 | targetInputDisabled: boolean | undefined; 27 | targetPlaceholder: string | undefined; 28 | }) { 29 | const { pubKey, targetInputDisabled, targetPlaceholder } = props; 30 | const selectedWallet = useWallet(); 31 | const { connection } = useConnection(); 32 | const queryClient = useQueryClient(); 33 | 34 | let pubKeyVal = pubKey; 35 | if (!pubKeyVal) { 36 | pubKeyVal = targetPlaceholder || ''; 37 | } 38 | 39 | let fromKeyVal = selectedWallet.publicKey?.toString(); 40 | if (!fromKeyVal) { 41 | fromKeyVal = 'unset'; 42 | } 43 | 44 | const [sol, setSol] = useState('0.01'); 45 | const [fromKey, setFromKey] = useState(fromKeyVal); 46 | const [toKey, setToKey] = useState(pubKeyVal); 47 | 48 | useEffect(() => { 49 | if (pubKeyVal) { 50 | setToKey(pubKeyVal); 51 | } 52 | }, [pubKeyVal]); 53 | useEffect(() => { 54 | if (fromKeyVal) { 55 | setFromKey(fromKeyVal); 56 | } 57 | }, [fromKeyVal]); 58 | 59 | return ( 60 | 61 | Transfer SOL 62 | 63 |
64 | 65 | 66 | SOL 67 | 68 | 69 | setSol(e.target.value)} 74 | /> 75 | {/* TODO: check to see if the from Account has enough, including TX costs if its to come from them */} 76 | {/* TODO: add a MAX button */} 77 | 78 | 79 | 80 | 81 | {/* TODO: add a switch to&from button */} 82 | 83 | {/* TODO: these can only be accounts we know the private key for ... */} 84 | {/* TODO: should be able to edit, paste and select from list populated from accountList */} 85 | 86 | From 87 | 88 | 89 |
90 | 91 | {prettifyPubkey(fromKey, PK_FORMAT_LENGTH)} 92 | 93 | 94 |
95 | 96 | 97 |
98 | 99 | 100 | 101 | To 102 | 103 | 104 | {targetInputDisabled ? ( 105 |
106 | 107 | {prettifyPubkey(toKey, PK_FORMAT_LENGTH)} 108 | 109 | 110 |
111 | ) : ( 112 | setToKey(e.target.value)} 117 | /> 118 | )} 119 | {/* TODO: add radio selector to choose where the TX cost comes from 120 | 121 | Transaction cost from To account (after transfer takes place) 122 | 123 | */} 124 | 125 |
126 | 127 | 128 | 129 | 163 | 164 | 165 |
166 |
167 |
168 | ); 169 | } 170 | 171 | function TransferSolButton(props: { 172 | pubKey: string | undefined; 173 | label: string | undefined; 174 | targetInputDisabled: boolean | undefined; 175 | targetPlaceholder: string | undefined; 176 | }) { 177 | const { pubKey, label, targetInputDisabled, targetPlaceholder } = props; 178 | const { status } = useAppSelector(selectValidatorNetworkState); 179 | 180 | return ( 181 | 191 | 198 | 199 | ); 200 | } 201 | 202 | export default TransferSolButton; 203 | -------------------------------------------------------------------------------- /src/renderer/components/WatchAccountButton.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { PublicKey } from '@solana/web3.js'; 3 | import { Col, Row } from 'react-bootstrap'; 4 | import Button from 'react-bootstrap/Button'; 5 | import Form from 'react-bootstrap/Form'; 6 | import OverlayTrigger from 'react-bootstrap/OverlayTrigger'; 7 | import Popover from 'react-bootstrap/Popover'; 8 | import { logger } from '../common/globals'; 9 | import { setSelected } from '../data/SelectedAccountsList/selectedAccountsState'; 10 | import { useAppDispatch } from '../hooks'; 11 | 12 | function WatchAcountPopover(props: { 13 | onWatch: (pk: string, b: boolean) => void; 14 | }) { 15 | const { onWatch } = props; 16 | 17 | const pubKeyVal = ''; 18 | 19 | const [toKey, setToKey] = useState(pubKeyVal); 20 | const [validationError, setValidationErr] = useState(); 21 | 22 | useEffect(() => { 23 | if (pubKeyVal) { 24 | setToKey(pubKeyVal); 25 | } 26 | }, [pubKeyVal]); 27 | 28 | useEffect(() => { 29 | if (!toKey) { 30 | setValidationErr(''); 31 | return; 32 | } 33 | // validate public key 34 | try { 35 | PublicKey.isOnCurve(toKey); 36 | setValidationErr(undefined); 37 | } catch (err) { 38 | setValidationErr('Invalid key'); 39 | logger.errror(err); 40 | } 41 | }, [toKey]); 42 | 43 | return ( 44 | 45 | Watch Account 46 | 47 |
48 | 49 | 50 | Public Key 51 | 52 | 53 | setToKey(e.target.value)} 58 | /> 59 | {validationError ? ( 60 | 64 | {validationError} 65 | 66 | ) : ( 67 | <> 68 | )} 69 | 70 | 71 | 72 | 73 | 74 | 75 | 84 | 85 | 86 |
87 |
88 |
89 | ); 90 | } 91 | 92 | function WatchAccountButton(props: { 93 | pinAccount: (pk: string, b: boolean) => void; 94 | }) { 95 | const { pinAccount } = props; 96 | const [show, setShow] = useState(false); 97 | const dispatch = useAppDispatch(); 98 | 99 | const handleWatch = (toKey, isPinned) => { 100 | pinAccount(toKey, isPinned); 101 | dispatch(setSelected(toKey)); 102 | setShow(false); 103 | }; 104 | 105 | return ( 106 | 112 | 121 | 122 | ); 123 | } 124 | 125 | export default WatchAccountButton; 126 | -------------------------------------------------------------------------------- /src/renderer/components/base/Chip.tsx: -------------------------------------------------------------------------------- 1 | import { css } from 'vite-plugin-inline-css-modules'; 2 | 3 | const classes = css` 4 | .chip { 5 | @apply bg-surface-100 border-2 border-surface-300/50 w-min p-1 px-2 rounded-full select-none cursor-pointer bg-opacity-10 transition duration-100 hover:bg-surface-300 whitespace-nowrap; 6 | 7 | &.active { 8 | @apply bg-primary-base; 9 | } 10 | } 11 | `; 12 | 13 | const Chip: React.FC< 14 | React.DetailedHTMLProps< 15 | React.ButtonHTMLAttributes, 16 | HTMLButtonElement 17 | > & { 18 | active?: boolean; 19 | } 20 | > = ({ children, active, ...rest }) => { 21 | return ( 22 | 29 | ); 30 | }; 31 | 32 | export default Chip; 33 | -------------------------------------------------------------------------------- /src/renderer/components/base/EditableText.tsx: -------------------------------------------------------------------------------- 1 | import { KeyboardEvent, useEffect, useRef, useState } from 'react'; 2 | import IconButton from './IconButton'; 3 | 4 | const EditableText: React.FC< 5 | { 6 | value: string; 7 | onSave: (value: string) => void; 8 | } & React.InputHTMLAttributes 9 | > = ({ value, onSave, ...rest }) => { 10 | const [editingValue, setEditingValue] = useState( 11 | undefined 12 | ); 13 | const [editing, setEditing] = useState(false); 14 | const input = useRef(null); 15 | 16 | useEffect(() => { 17 | input.current?.focus(); 18 | }, [editing]); 19 | 20 | const save = () => { 21 | onSave(editingValue || value); 22 | setEditing(false); 23 | }; 24 | 25 | const onKeyDown = (ev: KeyboardEvent) => { 26 | switch (ev.key) { 27 | case 'Enter': 28 | save(); 29 | break; 30 | case 'Escape': 31 | setEditing(false); 32 | break; 33 | default: 34 | break; 35 | } 36 | }; 37 | 38 | if (editing) { 39 | return ( 40 |
41 | setEditingValue(ev.currentTarget.value)} 45 | value={editingValue || value} 46 | ref={input} 47 | onKeyDown={onKeyDown} 48 | /> 49 |
50 | 51 | 52 | 53 | setEditing(false)} dense> 54 | 55 | 56 |
57 |
58 | ); 59 | } 60 | return ( 61 |
62 | {value || 'Unset'} 63 | setEditing(true)}> 64 | 65 | 66 |
67 | ); 68 | }; 69 | 70 | export default EditableText; 71 | -------------------------------------------------------------------------------- /src/renderer/components/base/IconButton.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import { css } from 'vite-plugin-inline-css-modules'; 3 | 4 | const classes = css` 5 | .btn { 6 | @apply p-3 hover:bg-contrast/20 active:bg-contrast/30 rounded-full transition duration-100; 7 | 8 | &.dense { 9 | @apply p-1; 10 | } 11 | } 12 | `; 13 | 14 | const IconButton: React.FC< 15 | { 16 | dense?: boolean; 17 | } & React.DetailedHTMLProps< 18 | React.ButtonHTMLAttributes, 19 | HTMLButtonElement 20 | > 21 | > = ({ children, className, dense, ...rest }) => { 22 | return ( 23 | 34 | ); 35 | }; 36 | 37 | export default IconButton; 38 | -------------------------------------------------------------------------------- /src/renderer/components/tokens/ActiveAccordionHeader.tsx: -------------------------------------------------------------------------------- 1 | // Couldn't nest a button in a button using the standard ActiveAccordionHeader 2 | 3 | import { useContext } from 'react'; 4 | import { AccordionContext, useAccordionButton } from 'react-bootstrap'; 5 | 6 | // https://react-bootstrap.github.io/components/accordion/#custom-toggle-with-expansion-awareness 7 | export function ActiveAccordionHeader({ children, eventKey, callback }) { 8 | const { activeEventKey } = useContext(AccordionContext); 9 | 10 | const decoratedOnClick = useAccordionButton( 11 | eventKey, 12 | () => callback && callback(eventKey) 13 | ); 14 | 15 | const isCurrentEventKey = activeEventKey === eventKey; 16 | 17 | return ( 18 |

19 |
{ 25 | // Stop buttons on header from also toggling the accordion 26 | if (e.currentTarget !== e.target) return; 27 | decoratedOnClick(e); 28 | }} 29 | > 30 | {children} 31 |
32 |

33 | ); 34 | } 35 | 36 | export default ActiveAccordionHeader; 37 | -------------------------------------------------------------------------------- /src/renderer/components/tokens/CreateNewMintButton.tsx: -------------------------------------------------------------------------------- 1 | import { toast } from 'react-toastify'; 2 | import * as sol from '@solana/web3.js'; 3 | 4 | import * as walletAdapter from '@solana/wallet-adapter-react'; 5 | import { Button } from 'react-bootstrap'; 6 | import { useQueryClient } from 'react-query'; 7 | 8 | import * as walletWeb3 from '../../wallet-adapter/web3'; 9 | 10 | import { logger } from '../../common/globals'; 11 | import { useAppSelector } from '../../hooks'; 12 | import { 13 | NetStatus, 14 | selectValidatorNetworkState, 15 | } from '../../data/ValidatorNetwork/validatorNetworkState'; 16 | 17 | async function createNewMint( 18 | connection: sol.Connection, 19 | payer: walletAdapter.WalletContextState, 20 | mintOwner: sol.PublicKey 21 | ): Promise { 22 | // TODO: extract to createMintButton 23 | 24 | logger.info('createMint', mintOwner.toString()); 25 | // https://github.com/solana-labs/solana-program-library/blob/f487f520bf10ca29bf8d491192b6ff2b4bf89710/token/js/src/actions/createMint.ts 26 | // const mint = await createMint( 27 | // connection, 28 | // myWallet, // Payer of the transaction 29 | // myWallet.publicKey, // Account that will control the minting 30 | // null, // Account that will control the freezing of the token 31 | // 0 // Location of the decimal place 32 | // ); 33 | const confirmOptions: sol.ConfirmOptions = { 34 | // using the global commitmentLevel = 'processed' causes this to error out 35 | commitment: 'finalized', 36 | }; 37 | // eslint-disable-next-line promise/no-nesting 38 | return walletWeb3 39 | .createMint( 40 | connection, 41 | payer, // Payer of the transaction 42 | mintOwner, // Account that will control the minting 43 | null, // Account that will control the freezing of the token 44 | 0, // Location of the decimal place 45 | undefined, // mint keypair - will be generated if not specified 46 | confirmOptions 47 | ) 48 | .then((newMint) => { 49 | logger.info('Minted ', newMint.toString()); 50 | 51 | return newMint; 52 | }) 53 | .catch((e) => { 54 | logger.error(e); 55 | throw e; 56 | }); 57 | } 58 | 59 | export async function ensureAtaFor( 60 | connection: sol.Connection, 61 | payer: walletAdapter.WalletContextState, 62 | newMint: sol.PublicKey, 63 | ATAFor: sol.PublicKey 64 | ): Promise { 65 | // Get the token account of the fromWallet Solana address. If it does not exist, create it. 66 | logger.info('getOrCreateAssociatedTokenAccount', newMint.toString()); 67 | 68 | try { 69 | const fromTokenAccount = await walletWeb3.getOrCreateAssociatedTokenAccount( 70 | connection, 71 | payer, 72 | newMint, 73 | ATAFor 74 | ); 75 | // updateFunderATA(fromTokenAccount.address); 76 | return fromTokenAccount.address; 77 | } catch (e) { 78 | logger.error( 79 | e, 80 | 'getOrCreateAssociatedTokenAccount ensuremyAta', 81 | newMint.toString() 82 | ); 83 | } 84 | return undefined; 85 | } 86 | 87 | function CreateNewMintButton(props: { 88 | connection: sol.Connection; 89 | fromKey: walletAdapter.WalletContextState; 90 | myWallet: sol.PublicKey | undefined; 91 | disabled: boolean; 92 | andThen: (newMint: sol.PublicKey) => sol.PublicKey; 93 | }) { 94 | const { connection, fromKey, myWallet, andThen, disabled } = props; 95 | const { status } = useAppSelector(selectValidatorNetworkState); 96 | const queryClient = useQueryClient(); 97 | 98 | return ( 99 | 138 | ); 139 | } 140 | 141 | export default CreateNewMintButton; 142 | -------------------------------------------------------------------------------- /src/renderer/components/tokens/MetaplexMintMetaDataView.tsx: -------------------------------------------------------------------------------- 1 | import * as sol from '@solana/web3.js'; 2 | import * as metaplex from '@metaplex/js'; 3 | 4 | import Accordion from 'react-bootstrap/esm/Accordion'; 5 | import { useWallet } from '@solana/wallet-adapter-react'; 6 | import { useQuery } from 'react-query'; 7 | 8 | import { queryTokenMetadata } from '../../data/accounts/getAccount'; 9 | import { selectValidatorNetworkState } from '../../data/ValidatorNetwork/validatorNetworkState'; 10 | import MetaplexTokenDataButton from './MetaplexTokenData'; 11 | 12 | import InlinePK from '../InlinePK'; 13 | import { ActiveAccordionHeader } from './ActiveAccordionHeader'; 14 | import { useAppSelector } from '../../hooks'; 15 | 16 | export function MetaplexMintMetaDataView(props: { mintKey: string }) { 17 | const { mintKey } = props; 18 | const fromKey = useWallet(); 19 | const { net } = useAppSelector(selectValidatorNetworkState); 20 | 21 | // TODO: this can't be here before the query 22 | // TODO: there's a better way in query v4 - https://tkdodo.eu/blog/offline-react-query 23 | // if (status !== NetStatus.Running) { 24 | // return ( 25 | // 26 | // 27 | // Validator Offline{' '} 28 | // 32 | // 33 | // 34 | //
Validator Offline
35 | //
36 | //
37 | // ); 38 | // } 39 | 40 | const { 41 | status: loadStatus, 42 | // error, 43 | data: metaInfo, 44 | } = useQuery( 45 | ['token-mint-meta', { net, pubKey: mintKey }], 46 | // TODO: need to be able to say "we errored, don't keep looking" - there doesn't need to be metadata... 47 | queryTokenMetadata, 48 | {} 49 | ); 50 | 51 | const mintEventKey = `${mintKey}_metaplex_info`; 52 | 53 | if (!mintKey) { 54 | return ( 55 | 56 | {}}> 57 | No Mint selected 58 | 59 | 60 |
No DATA
61 |
62 |
63 | ); 64 | } 65 | const mintPubKey = new sol.PublicKey(mintKey); 66 | 67 | // ("idle" or "error" or "loading" or "success"). 68 | if (loadStatus === 'loading') { 69 | return ( 70 | 71 | {}}> 72 | Loading Metaplex token info{' '} 73 | 74 | 75 | 76 |
No DATA
77 |
78 |
79 | ); 80 | } 81 | 82 | // logger.info('token metaInfo:', JSON.stringify(metaInfo)); 83 | 84 | if (!metaInfo || !metaInfo.data) { 85 | return ( 86 | 87 | {}}> 88 |
89 | Metaplex Info 90 | None 91 |
92 |
93 | 98 |
99 |
100 |
101 | ); 102 | } 103 | 104 | const canEditMetadata = 105 | metaInfo.data.updateAuthority === fromKey.publicKey?.toString() && 106 | metaInfo.data.isMutable; 107 | 108 | return ( 109 | 110 | {}}> 111 |
112 | Metadata 113 | 114 |
115 |
116 | 117 | {metaInfo?.data.data.symbol} 118 | 119 | :{' '} ({metaInfo?.data.data.name} ) 120 |
121 |
122 | 126 |
127 |
128 | 129 |
130 |           
131 |             {JSON.stringify(
132 |               metaInfo,
133 |               (k, v) => {
134 |                 if (k === 'data') {
135 |                   if (v.type || v.mint || v.name) {
136 |                     return v;
137 |                   }
138 |                   return `${JSON.stringify(v).substring(0, 32)} ...`;
139 |                 }
140 |                 return v;
141 |               },
142 |               2
143 |             )}
144 |           
145 |         
146 |
147 |
148 | ); 149 | } 150 | 151 | export default MetaplexMintMetaDataView; 152 | -------------------------------------------------------------------------------- /src/renderer/components/tokens/MetaplexTokenData.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import OverlayTrigger from 'react-bootstrap/OverlayTrigger'; 3 | import Popover from 'react-bootstrap/Popover'; 4 | import Button from 'react-bootstrap/Button'; 5 | import Form from 'react-bootstrap/Form'; 6 | import { Row, Col } from 'react-bootstrap'; 7 | import { toast } from 'react-toastify'; 8 | import { useConnection, useWallet } from '@solana/wallet-adapter-react'; 9 | import * as metaplex from '@metaplex/js'; 10 | import * as sol from '@solana/web3.js'; 11 | 12 | import { useQuery, useQueryClient } from 'react-query'; 13 | import { queryTokenMetadata } from '../../data/accounts/getAccount'; 14 | import { useAppSelector } from '../../hooks'; 15 | 16 | import { 17 | NetStatus, 18 | selectValidatorNetworkState, 19 | } from '../../data/ValidatorNetwork/validatorNetworkState'; 20 | 21 | const logger = window.electron.log; 22 | 23 | function DataPopover(props: { mintPubKey: sol.PublicKey }) { 24 | const { mintPubKey } = props; 25 | const selectedWallet = useWallet(); 26 | const { connection } = useConnection(); 27 | const { net } = useAppSelector(selectValidatorNetworkState); 28 | const queryClient = useQueryClient(); 29 | 30 | const pubKey = mintPubKey.toString(); 31 | const { 32 | status: loadStatus, 33 | // error, 34 | data: metaData, 35 | } = useQuery( 36 | ['token-mint-meta', { net, pubKey }], 37 | queryTokenMetadata, 38 | {} 39 | ); 40 | 41 | const [name, setName] = useState( 42 | metaData?.data?.data.name || 'Workbench token' 43 | ); 44 | const [symbol, setSymbol] = useState( 45 | metaData?.data?.data.symbol || 'WORKBENCH' 46 | ); 47 | const [uri, setUri] = useState( 48 | metaData?.data?.data.uri || 49 | 'https://github.com/workbenchapp/solana-workbench/' 50 | ); 51 | const [sellerFeeBasisPoints, setSellerFeeBasisPoints] = useState( 52 | metaData?.data?.data.sellerFeeBasisPoints || 10 53 | ); 54 | 55 | if (loadStatus !== 'success' && loadStatus !== 'error') { 56 | return loading; 57 | } 58 | 59 | async function createOurMintMetadata() { 60 | // Create a new token 61 | logger.info('createOurMintMetadata', mintPubKey); 62 | if (!mintPubKey) { 63 | return; 64 | } 65 | try { 66 | const metadataToSet = new metaplex.programs.metadata.MetadataDataData({ 67 | name, 68 | symbol, 69 | uri, 70 | sellerFeeBasisPoints, 71 | creators: null, // TODO: 72 | }); 73 | 74 | if ( 75 | metaData && 76 | metaData.data && 77 | metaData.data.mint === mintPubKey.toString() 78 | ) { 79 | // https://github.com/metaplex-foundation/js/blob/a4274ec97c6599dbfae8860ae2edc03f49d35d68/src/actions/updateMetadata.ts 80 | const meta = await metaplex.actions.updateMetadata({ 81 | connection, 82 | wallet: selectedWallet, 83 | editionMint: mintPubKey, 84 | /** An optional new {@link MetadataDataData} object to replace the current data. This will completely overwrite the data so all fields must be set explicitly. * */ 85 | newMetadataData: metadataToSet, 86 | // newUpdateAuthority?: PublicKey, 87 | // /** This parameter can only be set to true once after which it can't be reverted to false **/ 88 | // primarySaleHappened?: boolean, 89 | }); 90 | logger.info('update metadata', meta); 91 | } else { 92 | // https://github.com/metaplex-foundation/js/blob/a4274ec97c6599dbfae8860ae2edc03f49d35d68/src/actions/createMetadata.ts#L32 93 | const meta = await metaplex.actions.createMetadata({ 94 | connection, 95 | wallet: selectedWallet, 96 | editionMint: mintPubKey, 97 | metadataData: metadataToSet, 98 | }); 99 | logger.info('create metadata', meta); 100 | } 101 | 102 | // const meta = metaplex.programs.metadata.Metadata.load(conn, tokenPublicKey); 103 | } catch (e) { 104 | logger.error('metadata create', e); 105 | throw e; 106 | } 107 | } 108 | 109 | return ( 110 | 111 | Metaplex token metadata 112 | 113 |
114 | 115 | 116 | Name 117 | 118 | 119 | setName(e.target.value)} 124 | /> 125 | 126 | 127 | 128 | 129 | 130 | Symbol 131 | 132 | 133 | setSymbol(e.target.value)} 138 | /> 139 | 140 | 141 | 142 | 143 | 144 | Uri 145 | 146 | 147 | setUri(e.target.value)} 152 | /> 153 | 154 | 155 | 156 | 157 | 162 | 163 | Sellr basis points 164 | 165 | 166 | setSellerFeeBasisPoints(e.target.value)} 171 | /> 172 | {/* TODO: check to see if the from Account has enough, including TX costs if its to come from them */} 173 | {/* TODO: add a MAX button */} 174 | 175 | 176 | 177 | 178 | 179 | 180 | 213 | 214 | 215 |
216 |
217 |
218 | ); 219 | } 220 | 221 | function MetaplexTokenDataButton(props: { 222 | mintPubKey: sol.PublicKey | undefined; 223 | disabled: boolean; 224 | }) { 225 | const { mintPubKey, disabled } = props; 226 | const { status } = useAppSelector(selectValidatorNetworkState); 227 | 228 | if (!mintPubKey) { 229 | return <>; 230 | } 231 | 232 | return ( 233 | 239 | 248 | 249 | ); 250 | } 251 | 252 | export default MetaplexTokenDataButton; 253 | -------------------------------------------------------------------------------- /src/renderer/components/tokens/MintInfoView.tsx: -------------------------------------------------------------------------------- 1 | import * as sol from '@solana/web3.js'; 2 | 3 | import Accordion from 'react-bootstrap/esm/Accordion'; 4 | import { Button, Modal } from 'react-bootstrap'; 5 | import { toast } from 'react-toastify'; 6 | import { 7 | useConnection, 8 | useWallet, 9 | WalletContextState, 10 | } from '@solana/wallet-adapter-react'; 11 | import { useState } from 'react'; 12 | import * as walletWeb3 from '../../wallet-adapter/web3'; 13 | import { useAppSelector } from '../../hooks'; 14 | 15 | import { useParsedAccount } from '../../data/accounts/getAccount'; 16 | import { selectValidatorNetworkState } from '../../data/ValidatorNetwork/validatorNetworkState'; 17 | 18 | import { logger } from '../../common/globals'; 19 | import InlinePK from '../InlinePK'; 20 | import { ActiveAccordionHeader } from './ActiveAccordionHeader'; 21 | 22 | function ButtonWithConfirmation({ disabled, children, onClick, title }) { 23 | const [show, setShow] = useState(false); 24 | 25 | const handleClose = () => setShow(false); 26 | const handleShow = () => setShow(true); 27 | 28 | return ( 29 | <> 30 | 33 | 34 | 35 | 36 | {title} 37 | 38 | {children} 39 | 40 | 49 | 52 | 53 | 54 | 55 | ); 56 | } 57 | 58 | // TODO: need to trigger an update of a component like this automatically when the cetAccount cache notices a change... 59 | export async function closeMint( 60 | connection: sol.Connection, 61 | fromKey: WalletContextState, 62 | mintKey: sol.PublicKey, 63 | myWallet: sol.PublicKey 64 | ) { 65 | if (!myWallet) { 66 | logger.info('no myWallet', myWallet); 67 | return; 68 | } 69 | if (!mintKey) { 70 | logger.info('no mintKey', mintKey); 71 | return; 72 | } 73 | 74 | await walletWeb3.setAuthority( 75 | connection, 76 | fromKey, // Payer of the transaction fees 77 | mintKey, // Account 78 | myWallet, // Current authority 79 | 'MintTokens', // Authority type: "0" represents Mint Tokens 80 | null // Setting the new Authority to null 81 | ); 82 | } 83 | 84 | export function MintInfoView(props: { mintKey: string }) { 85 | const { mintKey } = props; 86 | const fromKey = useWallet(); 87 | const { connection } = useConnection(); 88 | const { net } = useAppSelector(selectValidatorNetworkState); 89 | 90 | const { 91 | loadStatus, 92 | account: mintInfo, 93 | error, 94 | } = useParsedAccount(net, mintKey, { 95 | retry: 2, // TODO: this is here because sometimes, we get given an accountInfo with no parsed data. 96 | }); 97 | logger.debug( 98 | `MintInfoView(${mintKey}): ${loadStatus} - ${error}: ${JSON.stringify( 99 | mintInfo 100 | )}` 101 | ); 102 | const mintEventKey = `${mintKey}_mint_info`; 103 | 104 | // ("idle" or "error" or "loading" or "success"). 105 | if ( 106 | loadStatus !== 'success' || 107 | !mintInfo || 108 | !mintInfo.accountInfo || 109 | !mintInfo.accountInfo.data?.parsed 110 | ) { 111 | logger.verbose( 112 | `something not ready for ${JSON.stringify(mintInfo)}: ${loadStatus}` 113 | ); 114 | 115 | return ( 116 | 117 | {}}> 118 | Loading Mint info 119 | 120 | 121 |
Loading Mint info
122 |
123 |
124 | ); 125 | } 126 | 127 | // logger.info('mintInfo:', JSON.stringify(mintInfo)); 128 | const hasAuthority = 129 | mintInfo.accountInfo.data?.parsed.info.mintAuthority === 130 | fromKey.publicKey?.toString(); 131 | const mintAuthorityIsNull = 132 | !mintInfo?.accountInfo.data?.parsed.info.mintAuthority; 133 | 134 | if (!mintInfo || mintInfo?.data) { 135 | // logger.error(`something undefined`); 136 | return ( 137 | 138 | {}}> 139 | Loading Mint data 140 | 141 | 142 |
Loading Mint data 
143 |
144 |
145 | ); 146 | } 147 | 148 | const supply = mintInfo?.accountInfo.data?.parsed.info.supply; 149 | 150 | return ( 151 | 152 | {}}> 153 |
154 | Mint 155 | 156 |
157 |
158 | {supply} token{supply > 1 && 's'} 159 |
160 |
161 | { 165 | if (!fromKey.publicKey) { 166 | return; 167 | } 168 | toast.promise( 169 | closeMint( 170 | connection, 171 | fromKey, 172 | new sol.PublicKey(mintKey), 173 | fromKey.publicKey 174 | ), 175 | { 176 | pending: `Close mint account submitted`, 177 | success: `Close mint account succeeded 👌`, 178 | error: `Close mint account failed 🤯`, 179 | } 180 | ); 181 | }} 182 | > 183 |
184 | Are you sure you want to close the token mint? This will set the 185 | update authority for the mint to null, and is not reversable. 186 |
187 |
188 | Mint: 189 |
190 |
191 |
192 |
193 | 194 |
195 |           Mint info: {JSON.stringify(mintInfo, null, 2)}
196 |         
197 |
198 |
199 | ); 200 | } 201 | 202 | export default MintInfoView; 203 | -------------------------------------------------------------------------------- /src/renderer/components/tokens/MintTokenToButton.tsx: -------------------------------------------------------------------------------- 1 | import { toast } from 'react-toastify'; 2 | import * as sol from '@solana/web3.js'; 3 | 4 | import * as walletAdapter from '@solana/wallet-adapter-react'; 5 | import { Button } from 'react-bootstrap'; 6 | import { useQueryClient } from 'react-query'; 7 | import * as walletWeb3 from '../../wallet-adapter/web3'; 8 | 9 | import { logger } from '../../common/globals'; 10 | import { useAppSelector } from '../../hooks'; 11 | import { 12 | NetStatus, 13 | selectValidatorNetworkState, 14 | } from '../../data/ValidatorNetwork/validatorNetworkState'; 15 | import { ensureAtaFor } from './CreateNewMintButton'; 16 | 17 | async function mintToken( 18 | connection: sol.Connection, 19 | payer: walletAdapter.WalletContextState, 20 | mintKey: sol.PublicKey, 21 | mintTo: sol.PublicKey 22 | ) { 23 | if (!mintTo) { 24 | logger.info('no mintTo', mintTo); 25 | return; 26 | } 27 | if (!mintKey) { 28 | logger.info('no mintKey', mintKey); 29 | return; 30 | } 31 | if (!payer.publicKey) { 32 | logger.info('no payer.publicKey', payer.publicKey); 33 | return; 34 | } 35 | const tokenAta = await ensureAtaFor(connection, payer, mintKey, mintTo); 36 | if (!tokenAta) { 37 | logger.info('no tokenAta', tokenAta); 38 | return; 39 | } 40 | 41 | // Minting 1 new token to the "fromTokenAccount" account we just returned/created. 42 | const signature = await walletWeb3.mintTo( 43 | connection, 44 | payer, // Payer of the transaction fees 45 | mintKey, // Mint for the account 46 | tokenAta, // Address of the account to mint to 47 | payer.publicKey, // Minting authority 48 | 1 // Amount to mint 49 | ); 50 | logger.info('SIGNATURE', signature); 51 | } 52 | 53 | function MintTokenToButton(props: { 54 | connection: sol.Connection; 55 | fromKey: walletAdapter.WalletContextState; 56 | mintKey: sol.PublicKey | undefined; 57 | mintTo: sol.PublicKey | undefined; 58 | disabled: boolean; 59 | andThen: () => void; 60 | }) { 61 | const { connection, fromKey, mintKey, mintTo, andThen, disabled } = props; 62 | const { status } = useAppSelector(selectValidatorNetworkState); 63 | const queryClient = useQueryClient(); 64 | 65 | return ( 66 | 99 | ); 100 | } 101 | 102 | export default MintTokenToButton; 103 | -------------------------------------------------------------------------------- /src/renderer/components/tokens/TokensListView.tsx: -------------------------------------------------------------------------------- 1 | import { Card, Accordion, Container } from 'react-bootstrap'; 2 | import * as sol from '@solana/web3.js'; 3 | import { useConnection, useWallet } from '@solana/wallet-adapter-react'; 4 | import { useQuery } from 'react-query'; 5 | import { queryTokenAccounts } from '../../data/accounts/getAccount'; 6 | import { selectValidatorNetworkState } from '../../data/ValidatorNetwork/validatorNetworkState'; 7 | 8 | import { useAppSelector } from '../../hooks'; 9 | import InlinePK from '../InlinePK'; 10 | import { MintInfoView } from './MintInfoView'; 11 | import { MetaplexMintMetaDataView } from './MetaplexMintMetaDataView'; 12 | import MintTokenToButton from './MintTokenToButton'; 13 | import TransferTokenButton from './TransferTokenButton'; 14 | import { ActiveAccordionHeader } from './ActiveAccordionHeader'; 15 | 16 | export function TokensListView(props: { pubKey: string | undefined }) { 17 | const { pubKey } = props; 18 | const { net } = useAppSelector(selectValidatorNetworkState); 19 | // TODO: cleanup - do we really need these here? 20 | const accountPubKey = pubKey ? new sol.PublicKey(pubKey) : undefined; 21 | const fromKey = useWallet(); // pay from wallet adapter 22 | const { connection } = useConnection(); 23 | 24 | // TODO: this can't be here before the query 25 | // TODO: there's a better way in query v4 - https://tkdodo.eu/blog/offline-react-query 26 | // if (status !== NetStatus.Running) { 27 | // return ( 28 | // 29 | // Validator Offline 30 | // 31 | //
Validator Offline
32 | //
33 | //
34 | // ); 35 | // } 36 | 37 | const { 38 | status: loadStatus, 39 | // error, 40 | data: tokenAccountsData, 41 | } = useQuery, Error>( 42 | ['parsed-token-account', { net, pubKey }], 43 | queryTokenAccounts 44 | ); 45 | 46 | if (!pubKey) { 47 | return <>; 48 | } 49 | 50 | const ataEventKey = `${pubKey}_info`; 51 | // ("idle" or "error" or "loading" or "success"). 52 | if (loadStatus !== 'success') { 53 | return ( 54 | 55 | {}}> 56 | Loading tokens list 57 | 58 | 59 |
Loading info 
60 |
61 |
62 | ); 63 | } 64 | 65 | const tokenAccounts = tokenAccountsData.value; 66 | 67 | return ( 68 | 69 | {tokenAccounts?.map( 70 | (tAccount: { 71 | pubkey: sol.PublicKey; 72 | account: sol.AccountInfo; 73 | }) => { 74 | const { amount } = tAccount.account.data.parsed.info.tokenAmount; 75 | 76 | // TODO: extract to its own component 77 | return ( 78 | 79 | 80 | 81 |
82 | 83 | 84 | {}} 87 | > 88 |
89 | ATA 90 | {' '} 94 |
95 |
96 | {amount} token{amount > 1 && 's'} 97 |
98 |
99 | {}} 114 | /> 115 | 126 |
127 |
128 | 129 |
130 |                           
131 |                             {JSON.stringify(tAccount.account, null, 2)}
132 |                           
133 |                         
134 |
135 |
136 | 139 | 142 |
143 |
144 |
145 |
146 | ); 147 | } 148 | )} 149 |
150 | ); 151 | } 152 | 153 | export default TokensListView; 154 | -------------------------------------------------------------------------------- /src/renderer/components/tokens/TransferTokenButton.tsx: -------------------------------------------------------------------------------- 1 | import { toast } from 'react-toastify'; 2 | import * as sol from '@solana/web3.js'; 3 | 4 | import * as walletAdapter from '@solana/wallet-adapter-react'; 5 | import { 6 | Button, 7 | Col, 8 | Form, 9 | OverlayTrigger, 10 | Popover, 11 | Row, 12 | } from 'react-bootstrap'; 13 | import { useEffect, useState } from 'react'; 14 | import { useQueryClient } from 'react-query'; 15 | import * as walletWeb3 from '../../wallet-adapter/web3'; 16 | 17 | import { logger } from '../../common/globals'; 18 | import { useAppSelector } from '../../hooks'; 19 | import { 20 | NetStatus, 21 | selectValidatorNetworkState, 22 | } from '../../data/ValidatorNetwork/validatorNetworkState'; 23 | import { ensureAtaFor } from './CreateNewMintButton'; 24 | 25 | async function transferTokenToReceiver( 26 | connection: sol.Connection, 27 | fromKey: walletAdapter.WalletContextState, 28 | mintKey: sol.PublicKey, 29 | transferFrom: sol.PublicKey, 30 | transferTo: sol.PublicKey, 31 | tokenCount: number 32 | ) { 33 | if (!transferTo) { 34 | logger.info('no transferTo', transferTo); 35 | return; 36 | } 37 | if (!mintKey) { 38 | logger.info('no mintKey', mintKey); 39 | return; 40 | } 41 | if (!fromKey.publicKey) { 42 | logger.info('no fromKey.publicKey', fromKey); 43 | return; 44 | } 45 | const fromAta = await ensureAtaFor( 46 | connection, 47 | fromKey, 48 | mintKey, 49 | transferFrom 50 | ); 51 | if (!fromAta) { 52 | logger.info('no fromAta', fromAta); 53 | return; 54 | } 55 | if (!transferTo) { 56 | logger.info('no transferTo', transferTo); 57 | return; 58 | } 59 | 60 | // Get the token account of the toWallet Solana address. If it does not exist, create it. 61 | logger.info('getOrCreateAssociatedTokenAccount'); 62 | const ataReceiver = await ensureAtaFor( 63 | connection, 64 | fromKey, 65 | mintKey, 66 | transferTo 67 | ); 68 | 69 | if (!ataReceiver) { 70 | logger.info('no ataReceiver', ataReceiver); 71 | return; 72 | } 73 | const signature = await walletWeb3.transfer( 74 | connection, 75 | fromKey, // Payer of the transaction fees 76 | fromAta, // Source account 77 | ataReceiver, // Destination account 78 | fromKey.publicKey, // Owner of the source account 79 | tokenCount // Number of tokens to transfer 80 | ); 81 | logger.info('SIGNATURE', signature); 82 | } 83 | 84 | /// /////////////////////////////////////////////////////////////////// 85 | 86 | function TransferTokenPopover(props: { 87 | connection: sol.Connection; 88 | fromKey: walletAdapter.WalletContextState; 89 | mintKey: string | undefined; 90 | transferFrom: string | undefined; 91 | }) { 92 | const { connection, fromKey, mintKey, transferFrom } = props; 93 | const queryClient = useQueryClient(); 94 | 95 | let pubKeyVal = ''; 96 | if (!pubKeyVal) { 97 | pubKeyVal = 'paste'; 98 | } 99 | 100 | let fromKeyVal = fromKey.publicKey?.toString(); 101 | if (!fromKeyVal) { 102 | fromKeyVal = 'unset'; 103 | } 104 | 105 | const [tokenCount, setTokenCount] = useState('1'); 106 | // const [fromKey, setFromKey] = useState(fromKeyVal); 107 | const [toKey, setToKey] = useState(''); 108 | 109 | useEffect(() => { 110 | if (pubKeyVal) { 111 | setToKey(pubKeyVal); 112 | } 113 | }, [pubKeyVal]); 114 | // useEffect(() => { 115 | // if (fromKeyVal) { 116 | // setFromKey(fromKeyVal); 117 | // } 118 | // }, [fromKeyVal]); 119 | 120 | return ( 121 | 122 | Transfer Tokens 123 | 124 |
125 | 126 | 127 | Number of tokens 128 | 129 | 130 | setTokenCount(e.target.value)} 135 | /> 136 | {/* TODO: check to see if the from Account has enough, including TX costs if its to come from them */} 137 | {/* TODO: add a MAX button */} 138 | 139 | 140 | 141 | 142 | {/* TODO: add a switch to&from button */} 143 | 144 | {/* TODO: these can only be accounts we know the private key for ... */} 145 | {/* TODO: should be able to edit, paste and select from list populated from accountList */} 146 | 147 | From 148 | 149 | 150 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | To 163 | 164 | 165 | setToKey(e.target.value)} 170 | /> 171 | 172 | {/* TODO: add radio selector to choose where the TX cost comes from */} 173 | Transaction costs, and Ata Rent from wallet 174 | 175 | 176 | 177 | 178 | 179 | 180 | 225 | 226 | 227 |
228 |
229 |
230 | ); 231 | } 232 | 233 | function TransferTokenButton(props: { 234 | connection: sol.Connection; 235 | fromKey: walletAdapter.WalletContextState; 236 | mintKey: string | undefined; 237 | transferFrom: string | undefined; 238 | disabled: boolean; 239 | }) { 240 | const { connection, fromKey, mintKey, transferFrom, disabled } = props; 241 | const { status } = useAppSelector(selectValidatorNetworkState); 242 | 243 | return ( 244 | 255 | 264 | 265 | ); 266 | } 267 | 268 | export default TransferTokenButton; 269 | -------------------------------------------------------------------------------- /src/renderer/data/Config/configState.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit'; 2 | import { useEffect } from 'react'; 3 | import { ConfigMap } from '../../../types/types'; 4 | import { logger } from '../../common/globals'; 5 | import { useAppDispatch, useAppSelector } from '../../hooks'; 6 | // https://redux.js.org/usage/usage-with-typescript#define-slice-state-and-action-types 7 | // eslint-disable-next-line import/no-cycle 8 | import { RootState } from '../../store'; 9 | 10 | export enum ConfigKey { 11 | AnalyticsEnabled = 'analytics_enabled', 12 | } 13 | 14 | export interface ConfigValues { 15 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 16 | [key: string]: any; 17 | } 18 | export interface ConfigState { 19 | loading: boolean; 20 | values: ConfigValues | undefined; 21 | } 22 | 23 | const initialState: ConfigState = { 24 | values: undefined, 25 | loading: true, 26 | }; 27 | 28 | export const configSlice = createSlice({ 29 | name: 'config', 30 | // `createSlice` will infer the state type from the `initialState` argument 31 | initialState, 32 | reducers: { 33 | setConfig: (state, action: PayloadAction) => { 34 | state.values = action.payload.values; 35 | state.loading = action.payload.loading; 36 | }, 37 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 38 | setConfigValue: ( 39 | state, 40 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 41 | action: PayloadAction<{ key: string; value: any }> 42 | ) => { 43 | if (state.values) { 44 | state.values[action.payload.key] = action.payload.value; 45 | window.promiseIpc 46 | .send('CONFIG-Set', action.payload.key, action.payload.value) 47 | .catch(logger.error); 48 | } 49 | }, 50 | }, 51 | }); 52 | 53 | export const configActions = configSlice.actions; 54 | export const { setConfig, setConfigValue } = configSlice.actions; 55 | 56 | export const selectConfigState = (state: RootState) => state.config; 57 | 58 | export default configSlice.reducer; 59 | 60 | export function useConfigState() { 61 | const config = useAppSelector(selectConfigState); 62 | const dispatch = useAppDispatch(); 63 | 64 | useEffect(() => { 65 | if (config.loading) { 66 | window.promiseIpc 67 | .send('CONFIG-GetAll') 68 | .then((ret: ConfigMap) => { 69 | dispatch( 70 | setConfig({ 71 | values: ret, 72 | loading: false, 73 | }) 74 | ); 75 | return `return ${ret}`; 76 | }) 77 | .catch((e: Error) => logger.error(e)); 78 | } 79 | }, [dispatch, config.loading, config.values]); 80 | 81 | return config; 82 | } 83 | -------------------------------------------------------------------------------- /src/renderer/data/SelectedAccountsList/selectedAccountsState.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit'; 2 | 3 | // https://redux.js.org/usage/usage-with-typescript#define-slice-state-and-action-types 4 | // eslint-disable-next-line import/no-cycle 5 | import { RootState } from '../../store'; 6 | import { loadState } from '../localstorage'; 7 | 8 | export interface SelectedAccountsList { 9 | pinnedAccounts: string[]; // list of pubKeys (TODO: should really add net...) 10 | selectedAccount: string; 11 | hoveredAccount: string; 12 | editedAccount: string; 13 | rootKey: string; 14 | } 15 | 16 | // Define the initial state using that type 17 | let initialState: SelectedAccountsList = { 18 | pinnedAccounts: [], 19 | selectedAccount: '', 20 | hoveredAccount: '', 21 | editedAccount: '', 22 | rootKey: '', 23 | }; 24 | const loaded = loadState('selectedaccounts'); 25 | if (loaded) { 26 | // work out the schema change (30mar2022) 27 | if (loaded.listedAccounts) { 28 | loaded.pinnedAccounts = loaded.listedAccounts; 29 | delete loaded.listedAccounts; 30 | } 31 | if (loaded.pinnedAccounts) { 32 | for (let i = 0; i < initialState.pinnedAccounts.length; i += 1) { 33 | let val = loaded.pinnedAccounts[i]; 34 | if (val instanceof Object) { 35 | val = val.pubKey; 36 | if (val) { 37 | loaded.pinnedAccounts[i] = val; 38 | } 39 | } 40 | } 41 | // ensure any listedAccount is only in the list once 42 | loaded.pinnedAccounts = Array.from(new Set(loaded.pinnedAccounts)); 43 | } 44 | initialState = loaded; 45 | } 46 | 47 | export const selectedAccountsListSlice = createSlice({ 48 | name: 'selectedaccounts', 49 | // `createSlice` will infer the state type from the `initialState` argument 50 | initialState, 51 | reducers: { 52 | setAccounts: (state, action: PayloadAction) => { 53 | state.pinnedAccounts = action.payload; 54 | }, 55 | setRootKey: (state, action: PayloadAction) => { 56 | state.rootKey = action.payload; 57 | }, 58 | shift: (state) => { 59 | state.pinnedAccounts.shift(); 60 | }, 61 | unshift: (state, action: PayloadAction) => { 62 | state.pinnedAccounts.unshift(action.payload); 63 | }, 64 | rm: (state, action: PayloadAction) => { 65 | state.pinnedAccounts = state.pinnedAccounts.filter( 66 | (a) => a !== action.payload 67 | ); 68 | }, 69 | setEdited: (state, action: PayloadAction) => { 70 | state.editedAccount = action.payload; 71 | }, 72 | setHovered: (state, action: PayloadAction) => { 73 | state.hoveredAccount = action.payload; 74 | }, 75 | setSelected: (state, action: PayloadAction) => { 76 | state.selectedAccount = action.payload; 77 | }, 78 | }, 79 | }); 80 | 81 | export const accountsActions = selectedAccountsListSlice.actions; 82 | export const { 83 | setAccounts, 84 | setRootKey, 85 | shift, 86 | unshift, 87 | rm, 88 | setEdited, 89 | setHovered, 90 | setSelected, 91 | } = selectedAccountsListSlice.actions; 92 | 93 | // Other code such as selectors can use the imported `RootState` type 94 | export const selectAccountsListState = (state: RootState) => 95 | state.selectedaccounts; 96 | 97 | export default selectedAccountsListSlice.reducer; 98 | -------------------------------------------------------------------------------- /src/renderer/data/ValidatorNetwork/ValidatorNetwork.tsx: -------------------------------------------------------------------------------- 1 | import * as sol from '@solana/web3.js'; 2 | import { useEffect } from 'react'; 3 | import Dropdown from 'react-bootstrap/Dropdown'; 4 | import DropdownButton from 'react-bootstrap/DropdownButton'; 5 | import { NavLink } from 'react-router-dom'; 6 | import { GetValidatorConnection, logger } from '../../common/globals'; 7 | import { useAppDispatch, useAppSelector, useInterval } from '../../hooks'; 8 | import { 9 | Net, 10 | NetStatus, 11 | selectValidatorNetworkState, 12 | setNet, 13 | setState, 14 | } from './validatorNetworkState'; 15 | 16 | const validatorState = async (net: Net): Promise => { 17 | let solConn: sol.Connection; 18 | 19 | // Connect to cluster 20 | try { 21 | solConn = GetValidatorConnection(net); 22 | await solConn.getEpochInfo(); 23 | } catch (error) { 24 | return NetStatus.Unavailable; 25 | } 26 | return NetStatus.Running; 27 | }; 28 | 29 | function ValidatorNetwork() { 30 | const validator = useAppSelector(selectValidatorNetworkState); 31 | const { net } = validator; 32 | const dispatch = useAppDispatch(); 33 | 34 | useEffect(() => { 35 | validatorState(net) 36 | .then((state) => { 37 | return dispatch(setState(state)); 38 | }) 39 | .catch(logger.info); 40 | }, [dispatch, net, validator]); 41 | 42 | const effect = () => {}; 43 | useEffect(effect, []); 44 | 45 | useInterval(() => { 46 | validatorState(net) 47 | .then((state) => { 48 | return dispatch(setState(state)); 49 | }) 50 | .catch((err) => { 51 | logger.debug(err); 52 | }); 53 | }, 11111); 54 | 55 | const netDropdownSelect = (eventKey: string | null) => { 56 | // TODO: analytics('selectNet', { prevNet: net, newNet: eventKey }); 57 | if (eventKey) { 58 | dispatch(setState(NetStatus.Unknown)); 59 | dispatch(setNet(eventKey as Net)); 60 | } 61 | }; 62 | 63 | let statusText = validator.status as string; 64 | let statusClass = 'text-red-500'; 65 | if (validator.status === NetStatus.Running) { 66 | statusText = 'Available'; 67 | statusClass = 'text-green-500'; 68 | } 69 | const statusDisplay = ( 70 | <> 71 | 72 | {statusText} 73 | 74 | ); 75 | 76 | const netDropdownTitle = ( 77 | <> 78 | {net} 79 | {statusDisplay} 80 | 81 | ); 82 | 83 | function GetLocalnetManageText() { 84 | if ( 85 | validator.net === Net.Localhost && 86 | validator.status !== NetStatus.Running 87 | ) { 88 | // TODO: consider using icons and having STOP, START, MANAGE... 89 | return Manage {Net.Localhost}; 90 | } 91 | return <>{Net.Localhost}; 92 | } 93 | 94 | return ( 95 | 101 | 102 | 103 | 104 | 105 | {Net.Dev} 106 | 107 | 108 | {Net.Test} 109 | 110 | 111 | {Net.MainnetBeta} 112 | 113 | 114 | ); 115 | } 116 | 117 | export default ValidatorNetwork; 118 | -------------------------------------------------------------------------------- /src/renderer/data/ValidatorNetwork/validatorNetworkState.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit'; 2 | 3 | // https://redux.js.org/usage/usage-with-typescript#define-slice-state-and-action-types 4 | // eslint-disable-next-line import/no-cycle 5 | import type { RootState } from '../../store'; 6 | 7 | // from https://react-redux.js.org/tutorials/typescript-quick-start 8 | 9 | export enum Net { 10 | Localhost = 'localhost', 11 | Dev = 'devnet', 12 | Test = 'testnet', 13 | MainnetBeta = 'mainnet-beta', 14 | } 15 | export enum NetStatus { 16 | Unknown = 'unknown', 17 | Running = 'running', 18 | Unavailable = 'unavailable', 19 | Starting = 'starting', 20 | } 21 | 22 | export const netToURL = (net: Net): string => { 23 | switch (net) { 24 | case Net.Localhost: 25 | return 'http://127.0.0.1:8899'; 26 | case Net.Dev: 27 | return 'https://api.devnet.solana.com'; 28 | case Net.Test: 29 | return 'https://api.testnet.solana.com'; 30 | case Net.MainnetBeta: 31 | return 'https://api.mainnet-beta.solana.com'; 32 | default: 33 | } 34 | return ''; 35 | }; 36 | 37 | export interface ValidatorState { 38 | net: Net; 39 | status: NetStatus; 40 | } 41 | 42 | // TODO: Using a global to let the electron solana-wallet-backend what network we're on (its not a react component) 43 | // Sven wasted too much time on this, and its only temporary until that txn sign code moves to the react backend. 44 | export const globalNetworkSet = { net: Net.Localhost }; 45 | 46 | // Define the initial state using that type 47 | const initialState: ValidatorState = { 48 | net: Net.Localhost, 49 | status: NetStatus.Unknown, 50 | }; 51 | 52 | export const validatorNetworkSlice = createSlice({ 53 | name: 'validatornetwork', 54 | // `createSlice` will infer the state type from the `initialState` argument 55 | initialState, 56 | reducers: { 57 | setNet: (state, action: PayloadAction) => { 58 | state.net = action.payload; 59 | globalNetworkSet.net = state.net; 60 | }, 61 | setState: (state, action: PayloadAction) => { 62 | state.status = action.payload; 63 | }, 64 | }, 65 | }); 66 | 67 | export const accountsActions = validatorNetworkSlice.actions; 68 | export const { setNet, setState } = validatorNetworkSlice.actions; 69 | 70 | // Other code such as selectors can use the imported `RootState` type 71 | export const selectValidatorNetworkState = (state: RootState) => 72 | state.validatornetwork; 73 | 74 | export default validatorNetworkSlice.reducer; 75 | -------------------------------------------------------------------------------- /src/renderer/data/accounts/account.ts: -------------------------------------------------------------------------------- 1 | import { AnyAction, Dispatch, ThunkDispatch } from '@reduxjs/toolkit'; 2 | import { WalletContextState } from '@solana/wallet-adapter-react'; 3 | import * as sol from '@solana/web3.js'; 4 | import { 5 | logger, 6 | commitmentLevel, 7 | GetValidatorConnection, 8 | } from '../../common/globals'; 9 | import { NewKeyPairInfo } from '../../../types/types'; 10 | import { ConfigState, setConfigValue } from '../Config/configState'; 11 | import { SelectedAccountsList } from '../SelectedAccountsList/selectedAccountsState'; 12 | import { Net, ValidatorState } from '../ValidatorNetwork/validatorNetworkState'; 13 | import { AccountsState, reloadFromMain } from './accountState'; 14 | 15 | export async function airdropSol( 16 | net: Net, 17 | toKey: string, 18 | solAmount: number | string 19 | ) { 20 | const to = new sol.PublicKey(toKey); 21 | const sols = 22 | typeof solAmount === 'number' ? solAmount : parseFloat(solAmount); 23 | 24 | const connection = GetValidatorConnection(net); 25 | 26 | const airdropSignature = await connection.requestAirdrop( 27 | to, 28 | sols * sol.LAMPORTS_PER_SOL 29 | ); 30 | 31 | await connection.confirmTransaction(airdropSignature); 32 | } 33 | 34 | export async function sendSolFromSelectedWallet( 35 | connection: sol.Connection, 36 | fromKey: WalletContextState, 37 | toKey: string, 38 | solAmount: string 39 | ) { 40 | const { publicKey, sendTransaction } = fromKey; 41 | if (!publicKey) { 42 | throw Error('no wallet selected'); 43 | } 44 | const toPublicKey = new sol.PublicKey(toKey); 45 | 46 | const lamports = sol.LAMPORTS_PER_SOL * parseFloat(solAmount); 47 | 48 | let signature: sol.TransactionSignature = ''; 49 | 50 | const transaction = new sol.Transaction().add( 51 | sol.SystemProgram.transfer({ 52 | fromPubkey: publicKey, 53 | toPubkey: toPublicKey, 54 | lamports, 55 | }) 56 | ); 57 | 58 | signature = await sendTransaction(transaction, connection); 59 | 60 | await connection.confirmTransaction(signature, commitmentLevel); 61 | } 62 | 63 | async function createNewAccount( 64 | dispatch?: ThunkDispatch< 65 | { 66 | validatornetwork: ValidatorState; 67 | selectedaccounts: SelectedAccountsList; 68 | config: ConfigState; 69 | account: AccountsState; 70 | }, 71 | undefined, 72 | AnyAction 73 | > & 74 | Dispatch 75 | ): Promise { 76 | return window.promiseIpc 77 | .send('ACCOUNT-CreateNew') 78 | .then((account: NewKeyPairInfo) => { 79 | if (dispatch) { 80 | dispatch(reloadFromMain()); 81 | } 82 | logger.info(`renderer received a new account${JSON.stringify(account)}`); 83 | const newKeypair = sol.Keypair.fromSeed(account.privatekey.slice(0, 32)); 84 | return newKeypair; 85 | }) 86 | .catch((e: Error) => { 87 | logger.error(e); 88 | throw e; 89 | }); 90 | } 91 | 92 | export async function getElectronStorageWallet( 93 | dispatch: ThunkDispatch< 94 | { 95 | validatornetwork: ValidatorState; 96 | selectedaccounts: SelectedAccountsList; 97 | config: ConfigState; 98 | account: AccountsState; 99 | }, 100 | undefined, 101 | AnyAction 102 | > & 103 | Dispatch, 104 | config: ConfigState, 105 | accounts: AccountsState 106 | ): Promise { 107 | // TODO: This will eventually move into an electron wallet module, with its promiseIPC bits abstracted, but not this month. 108 | if (config?.values?.ElectronAppStorageKeypair) { 109 | const account = 110 | accounts.accounts[config?.values?.ElectronAppStorageKeypair]; 111 | 112 | if (account) { 113 | const pk = new Uint8Array({ length: 64 }); 114 | // TODO: so i wanted a for loop, but somehow, all the magic TS stuff said nope. 115 | let i = 0; 116 | while (i < 64) { 117 | // const index = i.toString(); 118 | const value = account.privatekey[i]; 119 | pk[i] = value; 120 | i += 1; 121 | } 122 | // const pk = account.accounts[key].privatekey as Uint8Array; 123 | try { 124 | return await new Promise((resolve) => { 125 | resolve(sol.Keypair.fromSecretKey(pk)); 126 | }); 127 | } catch (e) { 128 | logger.error('useKeypair: ', e); 129 | } 130 | } 131 | } 132 | // if the config doesn't have a keypair set, make one.. 133 | return createNewAccount(dispatch) 134 | .then((keypair) => { 135 | dispatch( 136 | setConfigValue({ 137 | key: 'ElectronAppStorageKeypair', 138 | value: keypair.publicKey.toString(), 139 | }) 140 | ); 141 | return keypair; 142 | }) 143 | .catch((e) => { 144 | logger.error(e); 145 | throw e; 146 | }); 147 | } 148 | 149 | export default createNewAccount; 150 | -------------------------------------------------------------------------------- /src/renderer/data/accounts/accountInfo.ts: -------------------------------------------------------------------------------- 1 | import * as sol from '@solana/web3.js'; 2 | import { Net } from '../ValidatorNetwork/validatorNetworkState'; 3 | 4 | // from https://react-redux.js.org/tutorials/typescript-quick-start 5 | 6 | export const ACCOUNTS_NONE_KEY = 'none'; 7 | // TO store Net in the getAccount object, we also need to abstract that into a UID 8 | // An expanded sol.KeyedAccountInfo 9 | export interface AccountInfo { 10 | accountInfo: sol.AccountInfo | null; 11 | accountId: sol.PublicKey; 12 | pubKey: string; 13 | net?: Net; 14 | // updatedSlot: number // this should be used to update old account info 15 | 16 | // info from program Changes 17 | count: number; // count tracks how often this account has been seen 18 | solDelta: number; // difference between last change amount and this one 19 | maxDelta: number; // maxDelta represents the max change in SOL seen during session 20 | programID: string; 21 | } 22 | -------------------------------------------------------------------------------- /src/renderer/data/accounts/accountState.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit'; 2 | import * as sol from '@solana/web3.js'; 3 | import { useEffect } from 'react'; 4 | import { logger } from '../../common/globals'; 5 | import { useAppDispatch, useAppSelector } from '../../hooks'; 6 | // https://redux.js.org/usage/usage-with-typescript#define-slice-state-and-action-types 7 | // eslint-disable-next-line import/no-cycle 8 | import { RootState } from '../../store'; 9 | 10 | export interface AccountMetaValues { 11 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 12 | [key: string]: any; 13 | } 14 | export interface AccountMeta { 15 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 16 | [publickey: string]: AccountMetaValues; 17 | } 18 | export interface AccountsState { 19 | loading: boolean; 20 | accounts: AccountMeta; 21 | } 22 | 23 | const initialState: AccountsState = { 24 | accounts: {}, 25 | loading: true, 26 | }; 27 | 28 | export const accountSlice = createSlice({ 29 | name: 'account', 30 | // `createSlice` will infer the state type from the `initialState` argument 31 | initialState, 32 | reducers: { 33 | // TODO - this needs a better name - its a bulk, over-write from server/main 34 | setAccount: (state, action: PayloadAction) => { 35 | state.accounts = action.payload.accounts; 36 | state.loading = action.payload.loading; 37 | }, 38 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 39 | setAccountValues: ( 40 | state, 41 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 42 | action: PayloadAction<{ key: string; value: any }> 43 | ) => { 44 | if (!action.payload.key || action.payload.key === '') { 45 | return; 46 | } 47 | if (state.accounts) { 48 | logger.info( 49 | `renderer ACCOUNT-Set: overwriting meta for ${ 50 | action.payload.key 51 | } with ${JSON.stringify(action.payload.value)}` 52 | ); 53 | // TODO: need to merge existing key:value with incoming (and define how to delete a key..) 54 | state.accounts[action.payload.key] = action.payload.value; 55 | window.promiseIpc 56 | .send('ACCOUNT-Set', action.payload.key, action.payload.value) 57 | .catch(logger.error); 58 | } 59 | }, 60 | reloadFromMain: (state) => { 61 | logger.info('triggerReload accounts from main (setting loading = true)'); 62 | 63 | state.loading = true; 64 | }, 65 | }, 66 | }); 67 | 68 | export const accountActions = accountSlice.actions; 69 | export const { setAccountValues, reloadFromMain } = accountSlice.actions; 70 | 71 | export const selectAccountsState = (state: RootState) => state.account; 72 | 73 | export default accountSlice.reducer; 74 | 75 | const { setAccount } = accountSlice.actions; 76 | // get all accounts... 77 | export function useAccountsState() { 78 | const account = useAppSelector(selectAccountsState); 79 | const dispatch = useAppDispatch(); 80 | 81 | useEffect(() => { 82 | if (account.loading) { 83 | window.promiseIpc 84 | .send('ACCOUNT-GetAll') 85 | .then((ret: AccountMeta) => { 86 | logger.verbose('LOADING accounts from main'); 87 | dispatch( 88 | setAccount({ 89 | accounts: ret, 90 | loading: false, 91 | }) 92 | ); 93 | return `return ${ret}`; 94 | }) 95 | .catch((e: Error) => logger.error(e)); 96 | } 97 | }, [dispatch, account.loading, account.accounts]); 98 | 99 | return account; 100 | } 101 | 102 | // get a specific account 103 | export function useAccountMeta(key: string | undefined) { 104 | const account = useAccountsState(); 105 | 106 | if (!key || !account || account.loading || !account.accounts) { 107 | return undefined; 108 | } 109 | // exists to cater for the possibility that we need to do a round trip 110 | // for now, I'm just going to use the existing state 111 | return account.accounts[key]; 112 | } 113 | 114 | // get a specific account 115 | export function useKeypair(key: string | undefined) { 116 | const account = useAccountMeta(key); 117 | 118 | if ( 119 | !key || 120 | !account || 121 | account.loading || 122 | !account.accounts || 123 | !account.accounts[key] 124 | ) { 125 | return undefined; 126 | } 127 | const pk = new Uint8Array({ length: 64 }); 128 | // TODO: so i wanted a for loop, but somehow, all the magic TS stuff said nope. 129 | let i = 0; 130 | while (i < 64) { 131 | const index = i.toString(); 132 | const value = account.accounts[key].privatekey[index]; 133 | pk[i] = value; 134 | i += 1; 135 | } 136 | // const pk = account.accounts[key].privatekey as Uint8Array; 137 | try { 138 | return sol.Keypair.fromSecretKey(pk); 139 | } catch (e) { 140 | logger.error('useKeypair: ', e); 141 | } 142 | return undefined; 143 | } 144 | -------------------------------------------------------------------------------- /src/renderer/data/accounts/programChanges.ts: -------------------------------------------------------------------------------- 1 | import * as sol from '@solana/web3.js'; 2 | import { GetValidatorConnection, logger } from '../../common/globals'; 3 | import { Net } from '../ValidatorNetwork/validatorNetworkState'; 4 | import { AccountInfo } from './accountInfo'; 5 | import { peekAccount, updateCache } from './getAccount'; 6 | 7 | export interface ProgramChangesState { 8 | changes: AccountInfo[]; 9 | paused: boolean; 10 | } 11 | 12 | export interface ChangeLookupMap { 13 | [pubKey: string]: AccountInfo; 14 | } 15 | export interface ChangeSubscriptionMap { 16 | [net: string]: { 17 | [programID: string]: { 18 | subscriptionID: number; 19 | solConn: sol.Connection; 20 | }; 21 | }; 22 | } 23 | 24 | const changeSubscriptions: ChangeSubscriptionMap = {}; 25 | export const subscribeProgramChanges = async ( 26 | net: Net, 27 | programID: string, 28 | setValidatorSlot: (slot: number) => void 29 | ) => { 30 | let programIDPubkey: sol.PublicKey; 31 | if (programID === sol.SystemProgram.programId.toString()) { 32 | programIDPubkey = sol.SystemProgram.programId; 33 | } else { 34 | programIDPubkey = new sol.PublicKey(programID); 35 | } 36 | 37 | if ( 38 | !(net in changeSubscriptions) || 39 | !(programID in changeSubscriptions[net]) 40 | ) { 41 | logger.silly('subscribeProgramChanges', programID); 42 | 43 | const solConn = GetValidatorConnection(net); 44 | const subscriptionID = solConn.onProgramAccountChange( 45 | programIDPubkey, 46 | (info: sol.KeyedAccountInfo, ctx: sol.Context) => { 47 | if (setValidatorSlot) { 48 | setValidatorSlot(ctx.slot); 49 | } 50 | const pubKey = info.accountId.toString(); 51 | // logger.silly('programChange', pubKey); 52 | const solAmount = info.accountInfo.lamports / sol.LAMPORTS_PER_SOL; 53 | let [count, maxDelta, solDelta, prevSolAmount] = [1, 0, 0, 0]; 54 | 55 | const account = peekAccount(net, pubKey); 56 | if (account) { 57 | ({ count, maxDelta } = account); 58 | if (account.accountInfo) { 59 | prevSolAmount = account.accountInfo.lamports / sol.LAMPORTS_PER_SOL; 60 | solDelta = solAmount - prevSolAmount; 61 | if (Math.abs(solDelta) > Math.abs(maxDelta)) { 62 | maxDelta = solDelta; 63 | } 64 | } 65 | 66 | count += 1; 67 | } else { 68 | // logger.silly('new pubKey in programChange', pubKey); 69 | } 70 | 71 | const programAccountChange: AccountInfo = { 72 | net, 73 | pubKey, 74 | accountInfo: info.accountInfo, 75 | accountId: info.accountId, 76 | 77 | count, 78 | solDelta, 79 | maxDelta, 80 | programID, 81 | }; 82 | 83 | updateCache(programAccountChange); 84 | } 85 | ); 86 | changeSubscriptions[net] = { 87 | [programID]: { 88 | subscriptionID, 89 | solConn, 90 | }, 91 | }; 92 | } 93 | }; 94 | 95 | export const unsubscribeProgramChanges = async ( 96 | net: Net, 97 | programID: string 98 | ) => { 99 | const sub = changeSubscriptions[net][programID]; 100 | if (!sub) return; 101 | logger.silly('unsubscribeProgramChanges', programID); 102 | 103 | await sub.solConn.removeProgramAccountChangeListener(sub.subscriptionID); 104 | delete changeSubscriptions[net][programID]; 105 | }; 106 | -------------------------------------------------------------------------------- /src/renderer/data/localstorage.ts: -------------------------------------------------------------------------------- 1 | export const loadState = (stateName: string) => { 2 | try { 3 | const serializedState = localStorage.getItem(stateName); 4 | if (serializedState === null) { 5 | return undefined; 6 | } 7 | return JSON.parse(serializedState); 8 | } catch (err) { 9 | return undefined; 10 | } 11 | }; 12 | 13 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 14 | export const saveState = (stateName: string, state: any) => { 15 | try { 16 | const serializedState = JSON.stringify(state); 17 | localStorage.setItem(stateName, serializedState); 18 | } catch (err) { 19 | throw new Error(`Can't save changes in local storage: ${err}`); 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /src/renderer/hooks.ts: -------------------------------------------------------------------------------- 1 | import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; 2 | import { useEffect, useRef } from 'react'; 3 | import type { RootState, AppDispatch } from './store'; 4 | 5 | // Use throughout your app instead of plain `useDispatch` and `useSelector` 6 | export const useAppDispatch = () => useDispatch(); 7 | export const useAppSelector: TypedUseSelectorHook = useSelector; 8 | 9 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 10 | export const useInterval = (callback: any, delay: number) => { 11 | const savedCallback = useRef(() => {}); 12 | 13 | // Remember the latest callback. 14 | useEffect(() => { 15 | savedCallback.current = callback; 16 | }, [callback]); 17 | 18 | // Set up the interval. 19 | useEffect(() => { 20 | const tick = () => { 21 | savedCallback.current(); 22 | }; 23 | if (delay !== null) { 24 | const id = setInterval(tick, delay); 25 | return () => clearInterval(id); 26 | } 27 | return () => {}; 28 | }, [delay]); 29 | }; 30 | -------------------------------------------------------------------------------- /src/renderer/index.css: -------------------------------------------------------------------------------- 1 | html, body, #root { 2 | @apply bg-surface-500 w-full h-full; 3 | } 4 | 5 | ::-webkit-scrollbar { 6 | width: 10px; 7 | } 8 | 9 | ::-webkit-scrollbar-track { 10 | @apply bg-surface-400; 11 | } 12 | 13 | ::-webkit-scrollbar-thumb { 14 | @apply bg-surface-300; 15 | } 16 | 17 | ::-webkit-scrollbar-thumb:hover { 18 | @apply bg-surface-300; 19 | } 20 | 21 | textarea, input { 22 | @apply bg-surface-600 focus:outline-none focus:ring focus:ring-primary-light rounded-md focus:ring-2 ring-offset-2 ring-offset-surface-400 text-contrast p-2; 23 | &.dense { 24 | @apply p-1; 25 | } 26 | } 27 | 28 | svg { 29 | @apply align-text-bottom; 30 | } 31 | 32 | code { 33 | @apply overflow-ellipsis text-xs bg-surface-300 p-1 rounded-lg border-surface-100 border-1 mr-2; 34 | } 35 | 36 | .btn { 37 | @apply text-xs bg-primary-base p-1 text-contrast py-2 px-4 rounded-md cursor-pointer focus:outline outline-offset-2 outline-2 outline-primary-base; 38 | 39 | &.btn-sm { 40 | @apply py-2 px-3; 41 | } 42 | 43 | &:disabled { 44 | @apply opacity-50 bg-surface-100 cursor-auto; 45 | } 46 | } 47 | 48 | .gutter { 49 | @apply bg-surface-200 transition duration-50 active:bg-surface-100; 50 | 51 | &.gutter-horizontal { 52 | cursor: col-resize; 53 | } 54 | 55 | &.gutter-vertical { 56 | cursor: row-resize; 57 | } 58 | } 59 | 60 | /* TODO: move away from bootstrap to have nicer custom buttons and reduce bloat */ 61 | .popover-header { 62 | @apply bg-surface-300; 63 | } 64 | 65 | .popover-body, .dropdown-menu { 66 | @apply bg-surface-400 text-contrast; 67 | } 68 | 69 | .dropdown-item { 70 | @apply text-contrast hover:bg-surface-500 hover:text-contrast; 71 | } 72 | -------------------------------------------------------------------------------- /src/renderer/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Solana Workbench 6 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/renderer/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client'; 2 | import { Provider } from 'react-redux'; 3 | import { HashRouter } from 'react-router-dom'; 4 | import 'virtual:fonts.css'; 5 | import 'virtual:windi.css'; 6 | import { QueryClient, QueryClientProvider } from 'react-query'; 7 | 8 | import { Buffer } from 'buffer'; 9 | import App from './App'; 10 | import './index.css'; 11 | import store from './store'; 12 | 13 | window.Buffer = Buffer; 14 | 15 | const rootElement = document.getElementById('root'); 16 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 17 | const root = createRoot(rootElement!); 18 | 19 | const queryClient = new QueryClient({ 20 | defaultOptions: { 21 | queries: { 22 | // retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000), 23 | retry: false, 24 | staleTime: 10000, // milliSeconds 25 | cacheTime: 60000, // milliSeconds 26 | }, 27 | }, 28 | }); 29 | 30 | root.render( 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | ); 39 | -------------------------------------------------------------------------------- /src/renderer/nav/Account.tsx: -------------------------------------------------------------------------------- 1 | import Split from 'react-split'; 2 | import { 3 | NetStatus, 4 | selectValidatorNetworkState, 5 | } from '../data/ValidatorNetwork/validatorNetworkState'; 6 | import AccountView from '../components/AccountView'; 7 | import LogView from '../components/LogView'; 8 | import ProgramChangeView from '../components/ProgramChangeView'; 9 | import { selectAccountsListState } from '../data/SelectedAccountsList/selectedAccountsState'; 10 | import { useAppSelector } from '../hooks'; 11 | 12 | function Account() { 13 | const accounts = useAppSelector(selectAccountsListState); 14 | const validator = useAppSelector(selectValidatorNetworkState); 15 | const { selectedAccount } = accounts; 16 | 17 | return ( 18 | 24 | 30 | 31 |
32 |
33 | {validator.status === NetStatus.Running && 34 | selectedAccount !== '' && ( 35 | 36 | )} 37 |
38 |
39 |
40 |
41 | 42 |
43 |
44 | ); 45 | } 46 | 47 | export default Account; 48 | -------------------------------------------------------------------------------- /src/renderer/nav/Anchor.tsx: -------------------------------------------------------------------------------- 1 | /* eslint @typescript-eslint/no-explicit-any: off */ 2 | 3 | import { useEffect, useRef, useState } from 'react'; 4 | import { FormControl, InputGroup } from 'react-bootstrap'; 5 | 6 | function Anchor() { 7 | const programIDRef = useRef({} as HTMLInputElement); 8 | const [idl, setIDL] = useState({}); 9 | 10 | useEffect(() => { 11 | const listener = (resp: any) => { 12 | const { method, res } = resp; 13 | switch (method) { 14 | case 'fetch-anchor-idl': 15 | setIDL(res); 16 | break; 17 | default: 18 | } 19 | }; 20 | window.electron.ipcRenderer.on('main', listener); 21 | return () => { 22 | window.electron.ipcRenderer.removeListener('main', listener); 23 | }; 24 | }, []); 25 | 26 | return ( 27 |
28 |
29 |
30 | 31 | Program ID 32 | { 37 | window.electron.ipcRenderer.fetchAnchorIDL({ 38 | programID: programIDRef.current.value, 39 | }); 40 | }} 41 | /> 42 | 43 |
44 |
45 | {idl?.error ? ( 46 |
47 | {idl.error?.message} 48 |
49 | ) : ( 50 | '' 51 | )} 52 |
53 | {idl.instructions ? ( 54 | idl.instructions.map((instruction: any) => { 55 | return ( 56 |
57 |
58 | {instruction.name} 59 |
60 |
61 |
62 |
    63 |
  • Args
  • 64 | {instruction.args.map((arg: any) => { 65 | return ( 66 |
  • 67 | {arg.name} 68 | 69 | {arg.type.toString()} 70 | 71 |
  • 72 | ); 73 | })} 74 |
75 |
76 |
77 |
    78 |
  • Accounts
  • 79 | {instruction.accounts.map((account: any) => { 80 | return ( 81 |
  • {account.name}
  • 82 | ); 83 | })} 84 |
85 |
86 |
87 |
88 | ); 89 | }) 90 | ) : ( 91 | 92 | e.g.: GrAkKfEpTKQuVHG2Y97Y2FF4i7y7Q5AHLK94JBy7Y5yv 93 | 94 | )} 95 |
96 |
97 | ); 98 | } 99 | 100 | export default Anchor; 101 | -------------------------------------------------------------------------------- /src/renderer/nav/TokenPage.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import Split from 'react-split'; 3 | 4 | import Stack from 'react-bootstrap/Stack'; 5 | import { Row, Col, Form, Accordion } from 'react-bootstrap'; 6 | 7 | import * as sol from '@solana/web3.js'; 8 | import * as spltoken from '@solana/spl-token'; 9 | import { useQuery } from 'react-query'; 10 | import { useWallet } from '@solana/wallet-adapter-react'; 11 | import { queryTokenAccounts } from '../data/accounts/getAccount'; 12 | 13 | import { MetaplexMintMetaDataView } from '../components/tokens/MetaplexMintMetaDataView'; 14 | import { 15 | NetStatus, 16 | selectValidatorNetworkState, 17 | } from '../data/ValidatorNetwork/validatorNetworkState'; 18 | import { useAppSelector } from '../hooks'; 19 | import AccountView from '../components/AccountView'; 20 | import { MintInfoView } from '../components/tokens/MintInfoView'; 21 | 22 | function NotAbleToShowBanner({ children }) { 23 | return ( 24 |
25 |
26 | 30 | 35 | 36 | 37 | {children} 38 |
39 |
40 | ); 41 | } 42 | function MintAccordians({ mintKey }) { 43 | if (!mintKey) { 44 | return No Mint selected; 45 | } 46 | return ( 47 | <> 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | ); 58 | } 59 | 60 | function TokenPage() { 61 | const fromKey = useWallet(); 62 | const { net, status } = useAppSelector(selectValidatorNetworkState); 63 | 64 | // TODO: this will come from main config... 65 | const [mintList, updateMintList] = useState([]); 66 | const [mintKey, updateMintKey] = useState(); 67 | const { 68 | status: loadStatus, 69 | // error, 70 | data: tokenAccountsData, 71 | } = useQuery, Error>( 72 | ['parsed-token-account', { net, pubKey: fromKey.publicKey?.toString() }], 73 | queryTokenAccounts 74 | ); 75 | 76 | useEffect(() => { 77 | updateMintKey(undefined); 78 | }, [net, status]); 79 | 80 | const setMintPubKey = (pubKey: string | sol.PublicKey) => { 81 | if (typeof pubKey === 'string') { 82 | const key = new sol.PublicKey(pubKey); 83 | 84 | updateMintKey(key); 85 | } else { 86 | updateMintKey(pubKey); 87 | } 88 | }; 89 | 90 | useEffect(() => { 91 | if (!tokenAccountsData) { 92 | return; 93 | } 94 | const tokenAccounts = tokenAccountsData.value; 95 | 96 | const mints: sol.PublicKey[] = []; 97 | let foundMintKey = false; 98 | 99 | tokenAccounts?.map( 100 | (tAccount: { 101 | pubkey: sol.PublicKey; 102 | account: sol.AccountInfo; 103 | }) => { 104 | const accountState = tAccount.account.data.parsed 105 | .info as spltoken.Account; 106 | 107 | mints.push(accountState.mint); 108 | if (accountState.mint.toString() === mintKey?.toString()) { 109 | foundMintKey = true; 110 | } 111 | return mints; 112 | } 113 | ); 114 | if (!foundMintKey && mintKey) { 115 | updateMintKey(undefined); 116 | } 117 | 118 | updateMintList(mints); 119 | }, [mintKey, tokenAccountsData]); 120 | 121 | useEffect(() => { 122 | if (!mintKey && mintList.length > 0) { 123 | updateMintKey(mintList[0]); 124 | } 125 | }, [mintKey, mintList]); 126 | 127 | if (loadStatus !== 'success') { 128 | return
Loading token mints
; // TODO: need some "loading... ()" 129 | } 130 | 131 | if (!tokenAccountsData) { 132 | return
Loading token mints (still)
; 133 | } 134 | 135 | const { publicKey } = fromKey; 136 | if (!publicKey) { 137 | return
Loading wallet
; 138 | } 139 | const myWallet = publicKey; 140 | 141 | if (status !== NetStatus.Running) { 142 | return ( 143 | 144 | Unable to connect to selected Validator 145 | 146 | ); 147 | } 148 | 149 | return ( 150 | 151 | 152 | 153 | 154 | 155 | 156 | 162 | 163 | Our Wallet 164 | 165 | 166 | 167 | {mintList.length > 0 && ( 168 |
169 | Token Mint :{' '} 170 | 184 | 185 |
186 | )} 187 | 188 |
189 |
190 |
191 | ); 192 | } 193 | 194 | export default TokenPage; 195 | -------------------------------------------------------------------------------- /src/renderer/nav/ValidatorNetworkInfo.tsx: -------------------------------------------------------------------------------- 1 | import * as sol from '@solana/web3.js'; 2 | import { useEffect, useState } from 'react'; 3 | import { Col, Row } from 'react-bootstrap'; 4 | import Container from 'react-bootstrap/Container'; 5 | import { VictoryPie } from 'victory'; 6 | import { GetValidatorConnection, logger } from '../common/globals'; 7 | import { 8 | Net, 9 | selectValidatorNetworkState, 10 | } from '../data/ValidatorNetwork/validatorNetworkState'; 11 | import { useAppSelector } from '../hooks'; 12 | 13 | interface VersionCount { 14 | [key: string]: number; 15 | } 16 | export type VCount = { 17 | version: string; 18 | count: number; 19 | }; 20 | export type ValidatorNetworkInfoResponse = { 21 | version: string; 22 | nodes: sol.ContactInfo[]; 23 | versionCount: VCount[]; 24 | }; 25 | // https://docs.solana.com/developing/clients/jsonrpc-api#getclusternodes 26 | const fetchValidatorNetworkInfo = async (net: Net) => { 27 | const solConn = GetValidatorConnection(net); 28 | const contactInfo = await solConn.getClusterNodes(); 29 | // TODO: on success / failure update the ValidatorNetworkState.. 30 | const nodeVersion = await solConn.getVersion(); 31 | 32 | const frequencyCount: VersionCount = {}; 33 | 34 | contactInfo.map((info: sol.ContactInfo) => { 35 | let version = 'none'; 36 | if (info.version) { 37 | version = info.version; 38 | } 39 | 40 | if (frequencyCount[version]) { 41 | frequencyCount[version] += 1; 42 | } else { 43 | frequencyCount[version] = 1; 44 | } 45 | return undefined; 46 | }); 47 | const versions: VCount[] = []; 48 | Object.entries(frequencyCount).forEach(([version, count]) => { 49 | versions.push({ 50 | version, 51 | count, 52 | }); 53 | }); 54 | 55 | const response: ValidatorNetworkInfoResponse = { 56 | nodes: contactInfo, 57 | version: nodeVersion['solana-core'], 58 | versionCount: versions, 59 | }; 60 | 61 | return response; 62 | }; 63 | 64 | function ValidatorNetworkInfo() { 65 | const validator = useAppSelector(selectValidatorNetworkState); 66 | const { net } = validator; 67 | 68 | const [data, setData] = useState({ 69 | version: 'unknown', 70 | nodes: [], 71 | versionCount: [], 72 | }); 73 | useEffect(() => { 74 | // TODO: set a spinner while waiting for response 75 | fetchValidatorNetworkInfo(net) 76 | .then((d) => setData(d)) 77 | .catch(logger.info); 78 | }, [validator, net]); 79 | 80 | // TODO: maybe show te version spread as a histogram and feature info ala 81 | // solana --url mainnet-beta feature status 82 | return ( 83 | 84 | 85 | 86 | Current Network: 87 | {net} 88 | 89 | 90 | Current Version: 91 | {data.version} 92 | 93 | 94 | 95 | datum.version} 103 | x={(d) => (d as VCount).version} 104 | y={(d) => (d as VCount).count} 105 | /> 106 | 107 | 108 | ); 109 | } 110 | 111 | export default ValidatorNetworkInfo; 112 | -------------------------------------------------------------------------------- /src/renderer/public/themes/vantablack.css: -------------------------------------------------------------------------------- 1 | html { 2 | --surface-600: 1, 4, 9; 3 | --surface-500: 8, 12, 20; 4 | --surface-400: 15, 19, 25; 5 | --surface-300: 24, 29, 37; 6 | --surface-200: 35, 41, 49; 7 | --surface-100: 42, 48, 56; 8 | --contrast: 255, 255, 255; 9 | 10 | --primary-base: 131, 69, 255; 11 | --primary-light: 172, 133, 255; 12 | --primary-dark: 86, 3, 252; 13 | 14 | --secondary-base: 230, 0, 255; 15 | --secondary-light: 238, 84, 255; 16 | --secondary-dark: 23, 127, 179; 17 | 18 | color: white; 19 | } 20 | -------------------------------------------------------------------------------- /src/renderer/store.ts: -------------------------------------------------------------------------------- 1 | import { configureStore } from '@reduxjs/toolkit'; 2 | import throttle from 'lodash/throttle'; 3 | 4 | // https://redux.js.org/usage/usage-with-typescript#define-slice-state-and-action-types 5 | // eslint-disable-next-line import/no-cycle 6 | import ValidatorReducer from './data/ValidatorNetwork/validatorNetworkState'; 7 | // https://redux.js.org/usage/usage-with-typescript#define-slice-state-and-action-types 8 | // eslint-disable-next-line import/no-cycle 9 | import SelectedAccountsListReducer from './data/SelectedAccountsList/selectedAccountsState'; 10 | // https://redux.js.org/usage/usage-with-typescript#define-slice-state-and-action-types 11 | // eslint-disable-next-line import/no-cycle 12 | import ConfigReducer from './data/Config/configState'; 13 | // https://redux.js.org/usage/usage-with-typescript#define-slice-state-and-action-types 14 | // eslint-disable-next-line import/no-cycle 15 | import AccountReducer from './data/accounts/accountState'; 16 | 17 | import { saveState } from './data/localstorage'; 18 | 19 | const store = configureStore({ 20 | reducer: { 21 | validatornetwork: ValidatorReducer, 22 | selectedaccounts: SelectedAccountsListReducer, 23 | config: ConfigReducer, 24 | account: AccountReducer, 25 | }, 26 | }); 27 | 28 | // TODO: this is a really bad way to save a subset of redux - as its triggered any time anything changes 29 | // I think middleware is supposed to do it better 30 | store.subscribe( 31 | throttle(() => { 32 | saveState('selectedaccounts', store.getState().selectedaccounts); 33 | saveState('config', store.getState().config); 34 | saveState('account', store.getState().account); 35 | }, 1000) 36 | ); 37 | 38 | // Infer the `RootState` and `AppDispatch` types from the store itself 39 | export type RootState = ReturnType; 40 | export type AppDispatch = typeof store.dispatch; 41 | 42 | export default store; 43 | -------------------------------------------------------------------------------- /src/renderer/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "paths": { 6 | "@/*": ["./*"] 7 | } 8 | }, 9 | "include": ["../types"] 10 | } 11 | -------------------------------------------------------------------------------- /src/renderer/vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react'; 2 | import { join } from 'path'; 3 | import AutoImport from 'unplugin-auto-import/vite'; 4 | import IconsResolver from 'unplugin-icons/resolver'; 5 | import Icons from 'unplugin-icons/vite'; 6 | import { defineConfig } from 'vite'; 7 | import ViteFonts from 'vite-plugin-fonts'; 8 | import InlineCSSModules from 'vite-plugin-inline-css-modules'; 9 | import WindiCSS from 'vite-plugin-windicss'; 10 | import { NodeGlobalsPolyfillPlugin } from '@esbuild-plugins/node-globals-polyfill'; 11 | import checker from 'vite-plugin-checker'; 12 | import EnvironmentPlugin from 'vite-plugin-environment'; 13 | 14 | const PACKAGE_ROOT = __dirname; 15 | /** 16 | * @type {import('vite').UserConfig} 17 | * @see https://vitejs.dev/config/ 18 | */ 19 | export default defineConfig({ 20 | mode: process.env.MODE, 21 | root: PACKAGE_ROOT, 22 | resolve: { 23 | alias: { 24 | '@/': `${join(PACKAGE_ROOT, './')}/`, 25 | }, 26 | }, 27 | optimizeDeps: { 28 | esbuildOptions: { 29 | // Node.js global to browser globalThis 30 | define: { 31 | global: 'globalThis', 32 | }, 33 | // Enable esbuild polyfill plugins 34 | plugins: [ 35 | NodeGlobalsPolyfillPlugin({ 36 | buffer: true, 37 | }), 38 | ], 39 | target: 'es2021', 40 | }, 41 | }, 42 | plugins: [ 43 | EnvironmentPlugin({ 44 | BROWSER: 'true', // Anchor <=0.24.2 45 | ANCHOR_BROWSER: 'true', // Anchor >0.24.2 46 | }), 47 | ViteFonts({ 48 | google: { 49 | families: ['Roboto:wght@400;500;700', 'Space Mono:wght@400'], 50 | }, 51 | }), 52 | InlineCSSModules(), 53 | Icons({ 54 | compiler: 'jsx', 55 | jsx: 'react', 56 | }), 57 | AutoImport({ 58 | resolvers: [ 59 | IconsResolver({ 60 | prefix: 'Icon', 61 | extension: 'jsx', 62 | }), 63 | ], 64 | }), 65 | WindiCSS({ 66 | scan: { 67 | dirs: ['.'], // all files in the cwd 68 | fileExtensions: ['tsx', 'js', 'ts'], // also enabled scanning for js/ts 69 | }, 70 | }), 71 | react(), 72 | WindiCSS(), 73 | checker({ 74 | typescript: { 75 | root: PACKAGE_ROOT, 76 | tsconfigPath: `./tsconfig.json`, 77 | }, 78 | }), 79 | ], 80 | base: '', 81 | server: { 82 | fs: { 83 | strict: true, 84 | }, 85 | host: true, 86 | port: process.env.PORT ? +process.env.PORT : 1212, 87 | strictPort: true, 88 | }, 89 | build: { 90 | sourcemap: true, 91 | outDir: '../../release/dist/renderer', 92 | assetsDir: '.', 93 | emptyOutDir: true, 94 | brotliSize: false, 95 | target: 'es2021', 96 | }, 97 | }); 98 | -------------------------------------------------------------------------------- /src/renderer/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import viteConfig from './vite.config'; 3 | 4 | export default defineConfig({ 5 | ...viteConfig, 6 | test: { 7 | environment: 'jsdom', 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /src/renderer/windi.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'windicss/helpers'; 2 | 3 | function cssVarRgbHelper(cssVariable: string) { 4 | return ({ 5 | opacityVariable, 6 | opacityValue, 7 | }: { 8 | opacityVariable: string; 9 | opacityValue: number; 10 | }) => { 11 | if (opacityValue !== undefined) 12 | return `rgba(var(--${cssVariable}), ${opacityValue})`; 13 | 14 | if (opacityVariable !== undefined) 15 | return `rgba(var(--${cssVariable}), var(${opacityVariable}, 1))`; 16 | 17 | return `rgb(var(--${cssVariable}))`; 18 | }; 19 | } 20 | 21 | export default defineConfig({ 22 | theme: { 23 | extend: { 24 | fontFamily: { 25 | sans: ['"Roboto"'], 26 | mono: ['"Space Mono"'], 27 | }, 28 | colors: { 29 | primary: { 30 | base: cssVarRgbHelper('primary-base'), 31 | dark: cssVarRgbHelper('primary-dark'), 32 | light: cssVarRgbHelper('primary-light'), 33 | }, 34 | accent: { 35 | base: cssVarRgbHelper('accent-base'), 36 | dark: cssVarRgbHelper('accent-dark'), 37 | light: cssVarRgbHelper('accent-light'), 38 | }, 39 | surface: { 40 | 100: cssVarRgbHelper('surface-100'), 41 | 200: cssVarRgbHelper('surface-200'), 42 | 300: cssVarRgbHelper('surface-300'), 43 | 400: cssVarRgbHelper('surface-400'), 44 | 500: cssVarRgbHelper('surface-500'), 45 | 600: cssVarRgbHelper('surface-600'), 46 | }, 47 | contrast: cssVarRgbHelper('contrast'), 48 | }, 49 | }, 50 | }, 51 | }); 52 | -------------------------------------------------------------------------------- /src/types/hexdump-nodejs.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'hexdump-nodejs' { 2 | function hexdump( 3 | data: Buffer | Uint8Array, 4 | offset?: number, 5 | length?: number 6 | ): string; 7 | 8 | export = hexdump; 9 | } 10 | -------------------------------------------------------------------------------- /src/types/types.ts: -------------------------------------------------------------------------------- 1 | import * as sol from '@solana/web3.js'; 2 | 3 | export enum Net { 4 | Localhost = 'localhost', 5 | Dev = 'devnet', 6 | Test = 'testnet', 7 | MainnetBeta = 'mainnet-beta', 8 | } 9 | export enum NetStatus { 10 | Unknown = 'unknown', 11 | Running = 'running', 12 | Unavailable = 'unavailable', 13 | Starting = 'starting', 14 | } 15 | 16 | export const BASE58_PUBKEY_REGEX = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/; 17 | export const MAX_PROGRAM_CHANGES_DISPLAYED = 20; 18 | 19 | export enum ProgramID { 20 | SystemProgram = '11111111111111111111111111111111', 21 | SerumDEXV3 = '9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin', 22 | TokenProgram = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', 23 | } 24 | 25 | export enum ConfigAction { 26 | Get = 'get', 27 | Set = 'set', 28 | } 29 | 30 | export type ValidatorLogsRequest = { 31 | filter: string; 32 | net: Net; 33 | }; 34 | 35 | export type GetAccountRequest = { 36 | net: Net; 37 | pubKey: string; 38 | }; 39 | 40 | export type AccountsRequest = { 41 | net: Net; 42 | }; 43 | 44 | export type UpdateAccountRequest = { 45 | net: Net; 46 | pubKey: string; 47 | humanName: string; 48 | }; 49 | 50 | export type ImportAccountRequest = { 51 | net: Net; 52 | pubKey: string; 53 | }; 54 | 55 | export type ValidatorNetworkInfoRequest = { 56 | net: Net; 57 | }; 58 | 59 | export type ImportAccountResponse = { 60 | net: Net; 61 | }; 62 | 63 | export type SubscribeProgramChangesRequest = { 64 | net: Net; 65 | programID: string; 66 | }; 67 | 68 | export type UnsubscribeProgramChangesRequest = { 69 | net: Net; 70 | subscriptionID: number; 71 | programID: string; 72 | }; 73 | 74 | export type FetchAnchorIDLRequest = { 75 | programID: string; 76 | }; 77 | 78 | export interface ChangeSubscriptionMap { 79 | [net: string]: { 80 | [programID: string]: { 81 | subscriptionID: number; 82 | solConn: sol.Connection; 83 | }; 84 | }; 85 | } 86 | 87 | export interface LogSubscriptionMap { 88 | [net: string]: { 89 | subscriptionID: number; 90 | solConn: sol.Connection; 91 | }; 92 | } 93 | 94 | export interface AccountMap { 95 | [pubKey: string]: boolean; 96 | } 97 | 98 | export interface ConfigMap { 99 | [key: string]: string | undefined; 100 | } 101 | 102 | export type VCount = { 103 | version: string; 104 | count: number; 105 | }; 106 | 107 | export type ValidatorNetworkInfoResponse = { 108 | version: string; 109 | nodes: sol.ContactInfo[]; 110 | versionCount: VCount[]; 111 | }; 112 | 113 | // https://docs.solana.com/developing/clients/jsonrpc-api#getclusternodes 114 | export type NodeInfo = { 115 | pubkey: string; // - Node public key, as base-58 encoded string 116 | gossip: string | null; // - Gossip network address for the node 117 | tpu?: string | null; // - TPU network address for the node 118 | rpc?: string | null; // - JSON RPC network address for the node, or null if the JSON RPC service is not enabled 119 | version?: string | null; // - The software version of the node, or null if the version information is not available 120 | featureSet?: number | null; // - The unique identifier of the node's feature set 121 | shredVersion?: number | null; // - The shred version the node has been configured to use 122 | }; 123 | 124 | export interface NewKeyPairInfo { 125 | privatekey: Uint8Array; 126 | mnemonic: string; 127 | } 128 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "typeRoots": ["node_modules/@types"], 4 | "target": "es2021", 5 | "module": "commonjs", 6 | "lib": ["dom", "es2021"], 7 | "declaration": true, 8 | "declarationMap": true, 9 | "jsx": "react-jsx", 10 | "strict": true, 11 | "pretty": true, 12 | "sourceMap": true, 13 | "baseUrl": "./src", 14 | "noUnusedLocals": true, 15 | "noUnusedParameters": true, 16 | "noImplicitReturns": true, 17 | "noFallthroughCasesInSwitch": true, 18 | "moduleResolution": "node", 19 | "esModuleInterop": true, 20 | "allowSyntheticDefaultImports": true, 21 | "resolveJsonModule": true, 22 | "allowJs": true, 23 | "outDir": "release/app/dist", 24 | "skipLibCheck": true, 25 | "paths": { 26 | "@/*": ["src/*", "dist/*"] 27 | }, 28 | "types": ["unplugin-icons/types/react"] 29 | }, 30 | "exclude": ["test", "release"] 31 | } 32 | --------------------------------------------------------------------------------