├── .github ├── dependabot.yml └── workflows │ └── break_action.yml ├── .gitignore ├── .mergify.yml ├── LICENSE ├── Procfile ├── README.md ├── app.json ├── client ├── .eslintignore ├── .gitignore ├── .prettierignore ├── package-lock.json ├── package.json ├── public │ ├── favicon.ico │ ├── google.svg │ ├── index.html │ ├── manifest.json │ ├── rainbow192.png │ ├── rainbow512.png │ └── robots.txt ├── setupJest.ts ├── src │ ├── __tests__ │ │ └── bytes.ts │ ├── app.tsx │ ├── components │ │ ├── ClusterModal.tsx │ │ ├── ClusterStatusButton.tsx │ │ ├── ConfigurationSidebar.tsx │ │ ├── Header.tsx │ │ ├── LoadingModal.tsx │ │ ├── PaymentCard.tsx │ │ ├── SlotTableRow.tsx │ │ ├── TxContainer.tsx │ │ ├── TxModal.tsx │ │ ├── TxSquare.tsx │ │ └── TxTableRow.tsx │ ├── fonts │ │ ├── cerebrisans │ │ │ ├── cerebrisans-bold.eot │ │ │ ├── cerebrisans-bold.svg │ │ │ ├── cerebrisans-bold.ttf │ │ │ ├── cerebrisans-bold.woff │ │ │ ├── cerebrisans-book.eot │ │ │ ├── cerebrisans-book.svg │ │ │ ├── cerebrisans-book.ttf │ │ │ ├── cerebrisans-book.woff │ │ │ ├── cerebrisans-medium.eot │ │ │ ├── cerebrisans-medium.svg │ │ │ ├── cerebrisans-medium.ttf │ │ │ ├── cerebrisans-medium.woff │ │ │ ├── cerebrisans-regular.eot │ │ │ ├── cerebrisans-regular.svg │ │ │ ├── cerebrisans-regular.ttf │ │ │ ├── cerebrisans-regular.woff │ │ │ ├── cerebrisans-semibold.eot │ │ │ ├── cerebrisans-semibold.svg │ │ │ ├── cerebrisans-semibold.ttf │ │ │ └── cerebrisans-semibold.woff │ │ └── feather │ │ │ ├── feather.css │ │ │ └── fonts │ │ │ ├── Feather.svg │ │ │ ├── Feather.ttf │ │ │ └── Feather.woff │ ├── images │ │ ├── SOLANA-preview.png │ │ ├── break.svg │ │ ├── dashkit │ │ │ ├── covers │ │ │ │ ├── auth-side-cover.jpg │ │ │ │ ├── header-cover.jpg │ │ │ │ ├── profile-cover-1.jpg │ │ │ │ ├── profile-cover-2.jpg │ │ │ │ ├── profile-cover-3.jpg │ │ │ │ ├── profile-cover-4.jpg │ │ │ │ ├── profile-cover-5.jpg │ │ │ │ ├── profile-cover-6.jpg │ │ │ │ ├── profile-cover-7.jpg │ │ │ │ ├── profile-cover-8.jpg │ │ │ │ ├── sidebar-cover.jpg │ │ │ │ └── team-cover.jpg │ │ │ └── masks │ │ │ │ ├── avatar-group-hover-last.svg │ │ │ │ ├── avatar-group-hover.svg │ │ │ │ ├── avatar-group.svg │ │ │ │ ├── avatar-status.svg │ │ │ │ └── icon-status.svg │ │ ├── graphic.svg │ │ ├── hero.svg │ │ ├── icons │ │ │ ├── arrows.svg │ │ │ ├── capacity.svg │ │ │ ├── close.svg │ │ │ ├── cup.svg │ │ │ ├── loader.svg │ │ │ ├── processing.svg │ │ │ ├── table-cup.svg │ │ │ ├── tap.svg │ │ │ ├── timer.svg │ │ │ └── user.svg │ │ ├── logo.svg │ │ ├── share-facebook-2.svg │ │ ├── share-facebook.svg │ │ ├── share-twitter.svg │ │ └── solana.svg │ ├── index.tsx │ ├── pages │ │ ├── GamePage.tsx │ │ ├── HomePage.tsx │ │ ├── ResultsPage.tsx │ │ ├── SlotsPage.tsx │ │ ├── StartPage.tsx │ │ └── WalletPage.tsx │ ├── providers │ │ ├── accounts.tsx │ │ ├── config.tsx │ │ ├── game.tsx │ │ ├── rpc │ │ │ ├── balance.tsx │ │ │ ├── blockhash.tsx │ │ │ └── index.tsx │ │ ├── server │ │ │ ├── http │ │ │ │ ├── config.ts │ │ │ │ ├── index.tsx │ │ │ │ └── request.tsx │ │ │ ├── index.tsx │ │ │ └── socket.tsx │ │ ├── slot.tsx │ │ ├── torus.tsx │ │ ├── transactions │ │ │ ├── confirmed.tsx │ │ │ ├── create.tsx │ │ │ ├── index.tsx │ │ │ ├── selected.tsx │ │ │ └── tps.tsx │ │ └── wallet.tsx │ ├── react-app-env.d.ts │ ├── serviceWorker.ts │ ├── setupTests.ts │ ├── styles │ │ ├── animate.scss │ │ ├── custom-variables.scss │ │ ├── custom.scss │ │ ├── custom │ │ │ ├── _app.scss │ │ │ ├── _game.scss │ │ │ ├── _header.scss │ │ │ ├── _home.scss │ │ │ ├── _payment-card.scss │ │ │ ├── _results.scss │ │ │ ├── _sidebar.scss │ │ │ ├── _tx-container.scss │ │ │ └── _tx-square.scss │ │ ├── dashkit │ │ │ ├── _alert.scss │ │ │ ├── _avatar.scss │ │ │ ├── _badge.scss │ │ │ ├── _breadcrumb.scss │ │ │ ├── _buttons.scss │ │ │ ├── _card.scss │ │ │ ├── _chart.scss │ │ │ ├── _checklist.scss │ │ │ ├── _close.scss │ │ │ ├── _code.scss │ │ │ ├── _comment.scss │ │ │ ├── _custom-forms.scss │ │ │ ├── _dashkit.scss │ │ │ ├── _dropdowns.scss │ │ │ ├── _forms.scss │ │ │ ├── _header.scss │ │ │ ├── _icon.scss │ │ │ ├── _jumbotron.scss │ │ │ ├── _kanban.scss │ │ │ ├── _list-group.scss │ │ │ ├── _main-content.scss │ │ │ ├── _mixins.scss │ │ │ ├── _modal.scss │ │ │ ├── _nav.scss │ │ │ ├── _navbar.scss │ │ │ ├── _popover.scss │ │ │ ├── _progress.scss │ │ │ ├── _reboot.scss │ │ │ ├── _root.scss │ │ │ ├── _tables.scss │ │ │ ├── _toasts.scss │ │ │ ├── _type.scss │ │ │ ├── _utilities.scss │ │ │ ├── _variables.scss │ │ │ ├── _vendors.scss │ │ │ ├── dark │ │ │ │ ├── _overrides-dark.scss │ │ │ │ └── _variables-dark.scss │ │ │ ├── mixins │ │ │ │ ├── _badge.scss │ │ │ │ └── _breakpoints.scss │ │ │ ├── utilities │ │ │ │ ├── _background.scss │ │ │ │ ├── _borders.scss │ │ │ │ ├── _lift.scss │ │ │ │ ├── _sizing.scss │ │ │ │ └── _type.scss │ │ │ └── vendors │ │ │ │ ├── _dropzone.scss │ │ │ │ ├── _flatpickr.scss │ │ │ │ ├── _list.scss │ │ │ │ ├── _quill.scss │ │ │ │ └── _select2.scss │ │ ├── index.scss │ │ └── tools │ │ │ ├── shadows.scss │ │ │ └── transitions.scss │ ├── utils │ │ ├── bytes.ts │ │ └── index.ts │ ├── worker-loader.d.ts │ └── workers │ │ ├── create-transaction-rpc.ts │ │ └── create-transaction-worker-script.ts ├── svgTransform.js └── tsconfig.json ├── package-lock.json ├── package.json ├── package.sh ├── program ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── Xargo.toml ├── dist │ └── break_solana_program.so └── src │ └── lib.rs └── server ├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .prettierignore ├── package-lock.json ├── package.json ├── src ├── api.ts ├── available_nodes.ts ├── index.ts ├── leader_schedule.ts ├── leader_tracker.ts ├── program.ts ├── tpu_proxy.ts ├── urls.ts ├── utils.ts └── websocket.ts ├── tsconfig.json └── webpack.config.js /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: npm 9 | directory: "/client" 10 | schedule: 11 | interval: daily 12 | time: "01:00" 13 | timezone: America/Los_Angeles 14 | labels: 15 | - "automerge" 16 | open-pull-requests-limit: 3 17 | 18 | - package-ecosystem: npm 19 | directory: "/server" 20 | schedule: 21 | interval: daily 22 | time: "01:00" 23 | timezone: America/Los_Angeles 24 | labels: 25 | - "automerge" 26 | open-pull-requests-limit: 3 27 | -------------------------------------------------------------------------------- /.github/workflows/break_action.yml: -------------------------------------------------------------------------------- 1 | name: node_js 2 | on: 3 | push: 4 | branches: [main] 5 | pull_request: 6 | branches: [main] 7 | 8 | jobs: 9 | client: 10 | runs-on: ubuntu-latest 11 | env: 12 | PACKAGE_LOCATION: client 13 | steps: 14 | - name: Checkout repo 15 | uses: actions/checkout@v2 16 | - name: Use Node.js ${{ matrix.node-version }} 17 | uses: actions/setup-node@v2 18 | with: 19 | node-version: lts/* 20 | 21 | - name: Build 22 | run: | 23 | cd $PACKAGE_LOCATION 24 | npm ci 25 | npm run format 26 | npm run lint 27 | npm run build 28 | npm run test 29 | server: 30 | runs-on: ubuntu-latest 31 | env: 32 | PACKAGE_LOCATION: server 33 | steps: 34 | - name: Checkout repository 35 | uses: actions/checkout@v2 36 | - name: Use Node.js ${{ matrix.node-version }} 37 | uses: actions/setup-node@v2 38 | with: 39 | node-version: lts/* 40 | - name: Build 41 | run: | 42 | cd $PACKAGE_LOCATION 43 | npm ci 44 | npm run format 45 | npm run lint 46 | npm run build 47 | npm run test 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | build/ 3 | *.iml 4 | *.log 5 | /dist/ 6 | node_modules 7 | -------------------------------------------------------------------------------- /.mergify.yml: -------------------------------------------------------------------------------- 1 | pull_request_rules: 2 | - name: automatic merge on CI success 3 | conditions: 4 | - status-success=client 5 | - status-success=server 6 | - label=automerge 7 | actions: 8 | merge: 9 | method: rebase 10 | - name: remove automerge label on CI failure 11 | conditions: 12 | - label=automerge 13 | - or: 14 | - status-failure=client 15 | - status-failure=server 16 | actions: 17 | label: 18 | remove: 19 | - automerge 20 | - name: remove outdated reviews 21 | conditions: 22 | - base=main 23 | actions: 24 | dismiss_reviews: 25 | changes_requested: true 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 Solana Labs, Inc 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: npm run start 2 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "buildpacks": [ 3 | { 4 | "url": "heroku/nodejs" 5 | } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /client/.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | build 3 | webpack 4 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | build 3 | node_modules -------------------------------------------------------------------------------- /client/.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | build 3 | dashkit 4 | animate.scss 5 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@solana/break", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "start": "react-scripts start", 7 | "build": "react-scripts build", 8 | "test": "react-scripts test", 9 | "eject": "react-scripts eject", 10 | "lint": "echo \"lint is run during build\"", 11 | "format": "prettier -c \"**/*.+(js|jsx|ts|tsx|json|css|scss|md)\"", 12 | "format:fix": "prettier --write \"**/*.+(js|jsx|ts|tsx|json|css|scss|md)\"" 13 | }, 14 | "testnetDefaultChannel": "edge", 15 | "dependencies": { 16 | "@react-hook/debounce": "^4.0.0", 17 | "@react-hook/throttle": "^2.2.0", 18 | "@sentry/react": "^5.26.0", 19 | "@sentry/tracing": "^5.26.0", 20 | "@solana/web3.js": "^1.43.4", 21 | "@testing-library/jest-dom": "^5.16.4", 22 | "@testing-library/react": "^13.3.0", 23 | "@testing-library/user-event": "^14.2.0", 24 | "@toruslabs/fetch-node-details": "^6.0.1", 25 | "@toruslabs/torus.js": "^6.0.0", 26 | "@types/bs58": "^4.0.1", 27 | "@types/chai": "^4.3.1", 28 | "@types/chart.js": "^2.9.34", 29 | "@types/jest": "^27.5.1", 30 | "@types/node": "^17.0.35", 31 | "@types/qrcode.react": "^1.0.2", 32 | "@types/react": "^18.0.9", 33 | "@types/react-dom": "^17.0.14", 34 | "@types/react-router-dom": "^5.3.1", 35 | "@types/ua-parser-js": "^0.7.36", 36 | "bootstrap": "^4.6.0", 37 | "bs58": "^5.0.0", 38 | "buffer": "^6.0.3", 39 | "chai": "^4.3.6", 40 | "chart.js": "^2.9.4", 41 | "lodash.debounce": "^4.0.8", 42 | "node-sass": "^7.0.1", 43 | "prettier": "^2.6.2", 44 | "qrcode.react": "^3.0.2", 45 | "react": "^18.1.0", 46 | "react-chartjs-2": "^2.11.2", 47 | "react-dom": "^18.1.0", 48 | "react-google-login": "^5.2.2", 49 | "react-router-dom": "^5.3.0", 50 | "react-scripts": "^4.0.3", 51 | "tweetnacl": "^1.0.3", 52 | "typescript": "^4.7.2", 53 | "ua-parser-js": "^1.0.2", 54 | "worker-loader": "^3.0.8" 55 | }, 56 | "eslintConfig": { 57 | "extends": "react-app" 58 | }, 59 | "browserslist": { 60 | "production": [ 61 | ">0.2%", 62 | "not dead", 63 | "not op_mini all" 64 | ], 65 | "development": [ 66 | "last 1 chrome version", 67 | "last 1 firefox version", 68 | "last 1 safari version" 69 | ] 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solana-labs/break/c97989858498f056954649c0f8ae03862a805a4d/client/public/favicon.ico -------------------------------------------------------------------------------- /client/public/google.svg: -------------------------------------------------------------------------------- 1 | Google icon -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 17 | 21 | 22 | 26 | 27 | 36 | Break | Solana 37 | 41 | 42 | 43 | 44 |
45 | 46 | 47 | -------------------------------------------------------------------------------- /client/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Solana Explorer", 3 | "name": "Explorer for Solana clusters", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "rainbow192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "rainbow512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#75FBB4", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /client/public/rainbow192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solana-labs/break/c97989858498f056954649c0f8ae03862a805a4d/client/public/rainbow192.png -------------------------------------------------------------------------------- /client/public/rainbow512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solana-labs/break/c97989858498f056954649c0f8ae03862a805a4d/client/public/rainbow512.png -------------------------------------------------------------------------------- /client/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /client/setupJest.ts: -------------------------------------------------------------------------------- 1 | import { GlobalWithFetchMock } from "jest-fetch-mock"; 2 | 3 | const customGlobal: GlobalWithFetchMock = global as GlobalWithFetchMock; 4 | customGlobal.fetch = require("jest-fetch-mock"); 5 | customGlobal.fetchMock = customGlobal.fetch; 6 | -------------------------------------------------------------------------------- /client/src/__tests__/bytes.ts: -------------------------------------------------------------------------------- 1 | import * as Bytes from "../utils/bytes"; 2 | import { expect } from "chai"; 3 | 4 | describe("programDataToIds", () => { 5 | it("ids = []", () => { 6 | const ids = Bytes.programDataToIds(new Uint8Array([0])); 7 | expect(ids).to.eql([]); 8 | }); 9 | 10 | it("ids = [0]", () => { 11 | const ids = Bytes.programDataToIds(new Uint8Array([128])); 12 | expect(ids).to.eql([0]); 13 | }); 14 | 15 | it("ids = [8]", () => { 16 | const ids = Bytes.programDataToIds(new Uint8Array([0, 128])); 17 | expect(ids).to.eql([8]); 18 | }); 19 | 20 | it("ids = [7, 8]", () => { 21 | const ids = Bytes.programDataToIds(new Uint8Array([1, 128])); 22 | expect(ids).to.eql([7, 8]); 23 | }); 24 | }); 25 | 26 | describe("instructionDataFromId", () => { 27 | it("id = 0", () => { 28 | const data = Bytes.instructionDataFromId(0); 29 | expect(data).to.eql(new Uint8Array([0, 0])); 30 | }); 31 | 32 | it("id = 8", () => { 33 | const data = Bytes.instructionDataFromId(8); 34 | expect(data).to.eql(new Uint8Array([0, 8])); 35 | }); 36 | 37 | it("id = 257", () => { 38 | const data = Bytes.instructionDataFromId(257); 39 | expect(data).to.eql(new Uint8Array([1, 1])); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /client/src/app.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Route, Switch, Redirect, useRouteMatch } from "react-router-dom"; 3 | 4 | import { HomePage } from "pages/HomePage"; 5 | import { GamePage } from "pages/GamePage"; 6 | import { SlotsPage } from "pages/SlotsPage"; 7 | import { WalletPage } from "pages/WalletPage"; 8 | import { ResultsPage } from "pages/ResultsPage"; 9 | import { StartPage } from "pages/StartPage"; 10 | 11 | import { ClusterModal } from "components/ClusterModal"; 12 | import { LoadingModal } from "components/LoadingModal"; 13 | import { useGameState } from "providers/game"; 14 | import { useClusterModal } from "providers/server"; 15 | import { Header } from "components/Header"; 16 | import { ConfigurationSidebar } from "components/ConfigurationSidebar"; 17 | 18 | export default function App() { 19 | const isHomePage = !!useRouteMatch("/")?.isExact; 20 | const isWalletPage = !!useRouteMatch("/wallet")?.isExact; 21 | const isSlotsPage = !!useRouteMatch("/slots")?.isExact; 22 | const [showClusterModal] = useClusterModal(); 23 | const gameState = useGameState(); 24 | 25 | if (isHomePage) { 26 | return ( 27 |
28 | 29 |
30 | ); 31 | } 32 | 33 | const isLoading = gameState.loadingPhase !== "complete"; 34 | const isInitializing = 35 | gameState.loadingPhase === "config" && !isWalletPage && !isSlotsPage; 36 | const showLoadingModal = isLoading && !isWalletPage && !isSlotsPage; 37 | const showConfigSidebar = !isHomePage && !isWalletPage; 38 | return ( 39 |
40 |
41 |
42 |
43 | {showConfigSidebar && } 44 | {!isInitializing && ( 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | )} 54 |
55 |
56 | 57 | 58 | 59 |
60 | ); 61 | } 62 | 63 | function Overlay({ show }: { show: boolean }) { 64 | if (show) return
; 65 | return
; 66 | } 67 | -------------------------------------------------------------------------------- /client/src/components/ClusterModal.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Link } from "react-router-dom"; 3 | import { useDebounceCallback } from "@react-hook/debounce"; 4 | import { Location } from "history"; 5 | import { 6 | useServer, 7 | serverName, 8 | useClusterModal, 9 | SERVERS, 10 | DEFAULT_SERVER, 11 | useCustomUrl, 12 | } from "../providers/server"; 13 | import { useRpcUrlState } from "providers/rpc"; 14 | import { useServerConfig } from "providers/server/http"; 15 | 16 | export function ClusterModal() { 17 | const [show, setShow] = useClusterModal(); 18 | const onClose = () => setShow(false); 19 | const { server } = useServer(); 20 | return ( 21 | <> 22 |
26 |
27 |
28 |
e.stopPropagation()}> 29 | 30 | × 31 | 32 | 33 |

Choose a Cluster

34 | 35 | 36 | 37 |

38 | Override {serverName(server)} RPC 39 |

40 | 41 | 42 |
43 |
44 |
45 |
46 | 47 | ); 48 | } 49 | 50 | function CustomRpcInput() { 51 | const [rpcUrl, setRpcUrl] = useRpcUrlState(); 52 | const [editing, setEditing] = React.useState(false); 53 | const configRpcUrl = useServerConfig()?.rpcUrl; 54 | const active = configRpcUrl !== rpcUrl; 55 | 56 | const customClass = (prefix: string) => (active ? `${prefix}-info` : ""); 57 | const onUrlInput = useDebounceCallback((url: string) => { 58 | if (url.length > 0) { 59 | try { 60 | new URL(url); 61 | setRpcUrl(url); 62 | } catch (err) { 63 | // ignore bad url 64 | } 65 | } else if (configRpcUrl) { 66 | setRpcUrl(configRpcUrl); 67 | } 68 | }, 500); 69 | 70 | const defaultValue = active ? rpcUrl : ""; 71 | const inputTextClass = editing ? "" : "text-muted"; 72 | return ( 73 | setEditing(true)} 81 | onBlur={() => setEditing(false)} 82 | onInput={(e) => onUrlInput(e.currentTarget.value)} 83 | /> 84 | ); 85 | } 86 | 87 | function CustomClusterInput() { 88 | const [customUrl, setCustomUrl] = useCustomUrl(); 89 | const [editing, setEditing] = React.useState(false); 90 | 91 | const onUrlInput = useDebounceCallback((url: string) => { 92 | setCustomUrl(url); 93 | }, 500); 94 | 95 | const inputTextClass = editing ? "" : "text-muted"; 96 | return ( 97 | setEditing(true)} 102 | onBlur={() => setEditing(false)} 103 | onInput={(e) => onUrlInput(e.currentTarget.value)} 104 | /> 105 | ); 106 | } 107 | 108 | function ClusterToggle() { 109 | const { server } = useServer(); 110 | 111 | return ( 112 | <> 113 |
114 | {SERVERS.map((next) => { 115 | const active = next === server; 116 | const btnClass = active 117 | ? `active btn-dark border-info text-white` 118 | : "btn-dark"; 119 | 120 | const clusterLocation = (location: Location) => { 121 | const params = new URLSearchParams(location.search); 122 | if (next !== DEFAULT_SERVER) { 123 | params.set("cluster", next); 124 | } else { 125 | params.delete("cluster"); 126 | } 127 | return { 128 | ...location, 129 | search: params.toString(), 130 | }; 131 | }; 132 | 133 | return ( 134 | 139 | {serverName(next)} 140 | 141 | ); 142 | })} 143 |
144 | {server === "custom" && ( 145 | <> 146 |

Break Server URL

147 | 148 | 149 | )} 150 | 151 | ); 152 | } 153 | -------------------------------------------------------------------------------- /client/src/components/ClusterStatusButton.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useServer, useClusterModal } from "../providers/server"; 3 | 4 | function ClusterStatusButton() { 5 | const [, setShow] = useClusterModal(); 6 | const { name } = useServer(); 7 | return ( 8 | setShow(true)} 11 | > 12 | {name} 13 | 14 | ); 15 | } 16 | 17 | export default ClusterStatusButton; 18 | -------------------------------------------------------------------------------- /client/src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import breakSvg from "images/break.svg"; 4 | import solanaSvg from "images/solana.svg"; 5 | import { useGameState } from "providers/game"; 6 | import ClusterStatusButton from "./ClusterStatusButton"; 7 | import { useClientConfig } from "providers/config"; 8 | 9 | export function Header() { 10 | return ( 11 |
12 |
13 |
14 |
15 |
16 | break 17 | solana 18 |
19 |
20 |
21 | 22 |
23 | 24 |
25 | 31 | 32 | Source 33 | 34 |
35 |
36 |
37 |
38 |
39 | 40 |
41 |
42 |
43 |
44 |
45 | ); 46 | } 47 | 48 | function HeaderCTA() { 49 | const gameState = useGameState(); 50 | const [, setRefresh] = React.useState(false); 51 | const countdownStart = gameState.countdownStartTime; 52 | const gameStatus = gameState.status; 53 | const [{ countdownSeconds }] = useClientConfig(); 54 | 55 | React.useEffect(() => { 56 | if (countdownStart !== undefined) { 57 | const timerId = setInterval(() => { 58 | setRefresh((r) => !r); 59 | }, 1000); 60 | return () => clearTimeout(timerId); 61 | } 62 | }, [countdownStart]); 63 | 64 | if (gameStatus === "loading" || gameStatus === "setup") { 65 | return null; 66 | } 67 | 68 | if (gameStatus === "finished") { 69 | return ( 70 |
71 | Play Again 72 |
73 | ); 74 | } 75 | 76 | let secondsRemaining = countdownSeconds; 77 | if (countdownStart !== undefined) { 78 | secondsRemaining = Math.max( 79 | 0, 80 | countdownSeconds - Math.floor((performance.now() - countdownStart) / 1000) 81 | ); 82 | } 83 | 84 | return ( 85 |
86 |
87 | 88 |
89 |
90 | {secondsRemaining}s 91 |
92 |
93 | ); 94 | } 95 | -------------------------------------------------------------------------------- /client/src/components/LoadingModal.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { LoadingPhase } from "providers/game"; 4 | 5 | export function LoadingModal({ 6 | show, 7 | wallet, 8 | phase, 9 | }: { 10 | show: boolean; 11 | wallet?: boolean; 12 | phase?: LoadingPhase; 13 | }) { 14 | const renderContent = () => { 15 | if (!show) return null; 16 | let loadingText = "Loading"; 17 | if (wallet) { 18 | loadingText = "Fetching wallet"; 19 | } else if (phase) { 20 | switch (phase) { 21 | case "config": 22 | loadingText = "Initializing"; 23 | break; 24 | case "blockhash": 25 | loadingText = "Connecting to cluster"; 26 | break; 27 | case "costs": 28 | loadingText = "Calculating game cost"; 29 | break; 30 | case "socket": 31 | loadingText = "Connecting to transaction forwarder"; 32 | break; 33 | case "creating-accounts": 34 | loadingText = "Creating game accounts"; 35 | break; 36 | } 37 | } 38 | 39 | return ( 40 |
41 |
42 |
43 |
44 | 45 |

{loadingText}...

46 |
47 |
48 |
49 |
50 | ); 51 | }; 52 | 53 | return ( 54 |
{renderContent()}
55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /client/src/components/SlotTableRow.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { SlotTiming, useLeaderSchedule } from "providers/slot"; 4 | 5 | interface Props { 6 | timing?: SlotTiming; 7 | slot: number; 8 | } 9 | 10 | export function timeElapsed( 11 | sentAt: number | undefined, 12 | receivedAt: number | undefined 13 | ): string | undefined { 14 | if (sentAt === undefined || receivedAt === undefined) return; 15 | return (Math.max(0, receivedAt - sentAt) / 1000).toFixed(3) + "s"; 16 | } 17 | 18 | export function timestamp(sentAt: number | undefined): string | undefined { 19 | if (sentAt === undefined) return; 20 | const date = new Date(sentAt); 21 | const pad = (num: number, length: number) => 22 | num.toString().padStart(length, "0"); 23 | return `${pad(date.getHours(), 2)}:${pad(date.getMinutes(), 2)}:${pad( 24 | date.getSeconds(), 25 | 2 26 | )}.${pad(date.getMilliseconds(), 3)}`; 27 | } 28 | 29 | export function SlotTableRow({ slot, timing }: Props) { 30 | const schedule = useLeaderSchedule().current; 31 | function TdTimestamp({ time }: { time: number | undefined }) { 32 | return ( 33 | 34 |
35 | 36 | {timeElapsed(timing?.firstShred, time)} 37 | 38 |
{timestamp(time) || "-"}
39 |
40 | 41 | ); 42 | } 43 | 44 | const leader = React.useMemo(() => { 45 | if (schedule) { 46 | const [offset, leaderSchedule] = schedule; 47 | for (let [leader, slots] of Object.entries(leaderSchedule)) { 48 | if (slots.indexOf(slot - offset) >= 0) { 49 | return leader; 50 | } 51 | } 52 | } 53 | }, [slot, schedule]); 54 | 55 | let txCount, txSuccessRate, avgTpe; 56 | if (timing?.stats) { 57 | txCount = 58 | timing.stats.numSuccessfulTransactions + 59 | timing.stats.numFailedTransactions; 60 | const rawTxRate = timing.stats.numSuccessfulTransactions / txCount; 61 | txSuccessRate = `${(100 * rawTxRate).toFixed(1)}%`; 62 | avgTpe = (txCount / timing?.stats.numTransactionEntries).toFixed(1); 63 | } 64 | 65 | return ( 66 | 67 | {leader ? leader.slice(0, 7) : "-"} 68 | 69 |
70 | 71 | {timing?.parent + "┐" || "-"} 72 | 73 |
 {slot}
74 |
75 | 76 | {txCount || "-"} 77 | {txSuccessRate || "-"} 78 | {timing?.stats?.numTransactionEntries || "-"} 79 | {avgTpe || "-"} 80 | {timing?.stats?.maxTransactionsPerEntry || "-"} 81 | 82 | 83 | 84 | {timing?.err === undefined ? ( 85 | <> 86 | 87 | 88 | 89 | 90 | ) : ( 91 | <> 92 | 93 | {timing?.err} 94 | 95 | )} 96 | 97 | ); 98 | } 99 | -------------------------------------------------------------------------------- /client/src/components/TxSquare.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import "styles/animate.scss"; 4 | import { TransactionState } from "providers/transactions"; 5 | import { useSelectTransaction } from "providers/transactions/selected"; 6 | 7 | interface Props { 8 | transaction: TransactionState; 9 | } 10 | 11 | export function TransactionSquare({ transaction }: Props) { 12 | const { status, details } = transaction; 13 | const selectTransaction = useSelectTransaction(); 14 | 15 | let statusClass = ""; 16 | if (transaction.status === "success") { 17 | statusClass = "primary"; 18 | } else if (status === "failed") { 19 | statusClass = "danger"; 20 | } else if (status === "timeout") { 21 | statusClass = "warning"; 22 | } else { 23 | statusClass = "dark"; 24 | } 25 | 26 | return ( 27 |
selectTransaction(details.signature)} 29 | className={`btn d-flex flex-column justify-content-center align-items-center square slideInRight btn-${statusClass}`} 30 | >
31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /client/src/components/TxTableRow.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import "styles/animate.scss"; 4 | import { TransactionState } from "providers/transactions"; 5 | import { useSelectTransaction } from "providers/transactions/selected"; 6 | import { useSlotTiming } from "providers/slot"; 7 | import type { SlotTiming } from "providers/slot"; 8 | 9 | interface Props { 10 | transaction: TransactionState; 11 | } 12 | 13 | export function timeElapsed( 14 | sentAt: number | undefined, 15 | receivedAt: number | undefined 16 | ): string | undefined { 17 | if (sentAt === undefined || receivedAt === undefined) return; 18 | return (Math.max(0, receivedAt - sentAt) / 1000).toFixed(3) + "s"; 19 | } 20 | 21 | export function TxTableRow({ transaction }: Props) { 22 | const signature = transaction.details.signature; 23 | const selectTransaction = useSelectTransaction(); 24 | const slotMetrics = useSlotTiming(); 25 | 26 | let targetSlot; 27 | let landedSlot: number | undefined; 28 | let timing; 29 | let received; 30 | let targetSlotOverride: string | undefined; 31 | if (transaction.status === "success") { 32 | targetSlot = transaction.slot.target; 33 | landedSlot = transaction.slot.landed; 34 | timing = transaction.timing; 35 | received = transaction.received; 36 | } else if ( 37 | transaction.status === "timeout" || 38 | transaction.status === "failed" 39 | ) { 40 | targetSlotOverride = transaction.status.toUpperCase(); 41 | } else { 42 | targetSlot = transaction.pending.targetSlot; 43 | timing = transaction.timing; 44 | received = transaction.received; 45 | } 46 | 47 | let slotTiming: SlotTiming | undefined; 48 | let landedTime: number | undefined; 49 | if (landedSlot !== undefined) { 50 | landedTime = received?.find((r) => r.slot === landedSlot)?.timestamp; 51 | slotTiming = slotMetrics.current.get(landedSlot); 52 | } 53 | 54 | let txCount, txSuccessRate, avgTpe; 55 | if (slotTiming?.stats) { 56 | txCount = 57 | slotTiming.stats.numSuccessfulTransactions + 58 | slotTiming.stats.numFailedTransactions; 59 | const rawTxRate = slotTiming.stats.numSuccessfulTransactions / txCount; 60 | txSuccessRate = `${(100 * rawTxRate).toFixed(1)}%`; 61 | avgTpe = (txCount / slotTiming?.stats.numTransactionEntries).toFixed(1); 62 | } 63 | 64 | return ( 65 | selectTransaction(signature)} 68 | > 69 | {signature.slice(0, 7)}… 70 | {targetSlot || targetSlotOverride || "-"} 71 | {landedSlot || "-"} 72 | {txCount || "-"} 73 | {txSuccessRate || "-"} 74 | {slotTiming?.stats?.numTransactionEntries || "-"} 75 | {avgTpe || "-"} 76 | {slotTiming?.stats?.maxTransactionsPerEntry || "-"} 77 | {timeElapsed(timing?.subscribed, slotTiming?.firstShred) || "-"} 78 | {timeElapsed(timing?.subscribed, landedTime) || "-"} 79 | {timeElapsed(timing?.subscribed, slotTiming?.fullSlot) || "-"} 80 | {timeElapsed(timing?.subscribed, slotTiming?.createdBank) || "-"} 81 | {timeElapsed(timing?.subscribed, slotTiming?.frozen) || "-"} 82 | {timeElapsed(timing?.subscribed, slotTiming?.confirmed) || "-"} 83 | {timeElapsed(timing?.subscribed, slotTiming?.rooted) || "-"} 84 | 85 | ); 86 | } 87 | -------------------------------------------------------------------------------- /client/src/fonts/cerebrisans/cerebrisans-bold.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solana-labs/break/c97989858498f056954649c0f8ae03862a805a4d/client/src/fonts/cerebrisans/cerebrisans-bold.eot -------------------------------------------------------------------------------- /client/src/fonts/cerebrisans/cerebrisans-bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solana-labs/break/c97989858498f056954649c0f8ae03862a805a4d/client/src/fonts/cerebrisans/cerebrisans-bold.ttf -------------------------------------------------------------------------------- /client/src/fonts/cerebrisans/cerebrisans-bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solana-labs/break/c97989858498f056954649c0f8ae03862a805a4d/client/src/fonts/cerebrisans/cerebrisans-bold.woff -------------------------------------------------------------------------------- /client/src/fonts/cerebrisans/cerebrisans-book.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solana-labs/break/c97989858498f056954649c0f8ae03862a805a4d/client/src/fonts/cerebrisans/cerebrisans-book.eot -------------------------------------------------------------------------------- /client/src/fonts/cerebrisans/cerebrisans-book.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solana-labs/break/c97989858498f056954649c0f8ae03862a805a4d/client/src/fonts/cerebrisans/cerebrisans-book.ttf -------------------------------------------------------------------------------- /client/src/fonts/cerebrisans/cerebrisans-book.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solana-labs/break/c97989858498f056954649c0f8ae03862a805a4d/client/src/fonts/cerebrisans/cerebrisans-book.woff -------------------------------------------------------------------------------- /client/src/fonts/cerebrisans/cerebrisans-medium.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solana-labs/break/c97989858498f056954649c0f8ae03862a805a4d/client/src/fonts/cerebrisans/cerebrisans-medium.eot -------------------------------------------------------------------------------- /client/src/fonts/cerebrisans/cerebrisans-medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solana-labs/break/c97989858498f056954649c0f8ae03862a805a4d/client/src/fonts/cerebrisans/cerebrisans-medium.ttf -------------------------------------------------------------------------------- /client/src/fonts/cerebrisans/cerebrisans-medium.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solana-labs/break/c97989858498f056954649c0f8ae03862a805a4d/client/src/fonts/cerebrisans/cerebrisans-medium.woff -------------------------------------------------------------------------------- /client/src/fonts/cerebrisans/cerebrisans-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solana-labs/break/c97989858498f056954649c0f8ae03862a805a4d/client/src/fonts/cerebrisans/cerebrisans-regular.eot -------------------------------------------------------------------------------- /client/src/fonts/cerebrisans/cerebrisans-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solana-labs/break/c97989858498f056954649c0f8ae03862a805a4d/client/src/fonts/cerebrisans/cerebrisans-regular.ttf -------------------------------------------------------------------------------- /client/src/fonts/cerebrisans/cerebrisans-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solana-labs/break/c97989858498f056954649c0f8ae03862a805a4d/client/src/fonts/cerebrisans/cerebrisans-regular.woff -------------------------------------------------------------------------------- /client/src/fonts/cerebrisans/cerebrisans-semibold.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solana-labs/break/c97989858498f056954649c0f8ae03862a805a4d/client/src/fonts/cerebrisans/cerebrisans-semibold.eot -------------------------------------------------------------------------------- /client/src/fonts/cerebrisans/cerebrisans-semibold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solana-labs/break/c97989858498f056954649c0f8ae03862a805a4d/client/src/fonts/cerebrisans/cerebrisans-semibold.ttf -------------------------------------------------------------------------------- /client/src/fonts/cerebrisans/cerebrisans-semibold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solana-labs/break/c97989858498f056954649c0f8ae03862a805a4d/client/src/fonts/cerebrisans/cerebrisans-semibold.woff -------------------------------------------------------------------------------- /client/src/fonts/feather/fonts/Feather.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solana-labs/break/c97989858498f056954649c0f8ae03862a805a4d/client/src/fonts/feather/fonts/Feather.ttf -------------------------------------------------------------------------------- /client/src/fonts/feather/fonts/Feather.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solana-labs/break/c97989858498f056954649c0f8ae03862a805a4d/client/src/fonts/feather/fonts/Feather.woff -------------------------------------------------------------------------------- /client/src/images/SOLANA-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solana-labs/break/c97989858498f056954649c0f8ae03862a805a4d/client/src/images/SOLANA-preview.png -------------------------------------------------------------------------------- /client/src/images/dashkit/covers/auth-side-cover.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solana-labs/break/c97989858498f056954649c0f8ae03862a805a4d/client/src/images/dashkit/covers/auth-side-cover.jpg -------------------------------------------------------------------------------- /client/src/images/dashkit/covers/header-cover.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solana-labs/break/c97989858498f056954649c0f8ae03862a805a4d/client/src/images/dashkit/covers/header-cover.jpg -------------------------------------------------------------------------------- /client/src/images/dashkit/covers/profile-cover-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solana-labs/break/c97989858498f056954649c0f8ae03862a805a4d/client/src/images/dashkit/covers/profile-cover-1.jpg -------------------------------------------------------------------------------- /client/src/images/dashkit/covers/profile-cover-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solana-labs/break/c97989858498f056954649c0f8ae03862a805a4d/client/src/images/dashkit/covers/profile-cover-2.jpg -------------------------------------------------------------------------------- /client/src/images/dashkit/covers/profile-cover-3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solana-labs/break/c97989858498f056954649c0f8ae03862a805a4d/client/src/images/dashkit/covers/profile-cover-3.jpg -------------------------------------------------------------------------------- /client/src/images/dashkit/covers/profile-cover-4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solana-labs/break/c97989858498f056954649c0f8ae03862a805a4d/client/src/images/dashkit/covers/profile-cover-4.jpg -------------------------------------------------------------------------------- /client/src/images/dashkit/covers/profile-cover-5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solana-labs/break/c97989858498f056954649c0f8ae03862a805a4d/client/src/images/dashkit/covers/profile-cover-5.jpg -------------------------------------------------------------------------------- /client/src/images/dashkit/covers/profile-cover-6.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solana-labs/break/c97989858498f056954649c0f8ae03862a805a4d/client/src/images/dashkit/covers/profile-cover-6.jpg -------------------------------------------------------------------------------- /client/src/images/dashkit/covers/profile-cover-7.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solana-labs/break/c97989858498f056954649c0f8ae03862a805a4d/client/src/images/dashkit/covers/profile-cover-7.jpg -------------------------------------------------------------------------------- /client/src/images/dashkit/covers/profile-cover-8.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solana-labs/break/c97989858498f056954649c0f8ae03862a805a4d/client/src/images/dashkit/covers/profile-cover-8.jpg -------------------------------------------------------------------------------- /client/src/images/dashkit/covers/sidebar-cover.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solana-labs/break/c97989858498f056954649c0f8ae03862a805a4d/client/src/images/dashkit/covers/sidebar-cover.jpg -------------------------------------------------------------------------------- /client/src/images/dashkit/covers/team-cover.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solana-labs/break/c97989858498f056954649c0f8ae03862a805a4d/client/src/images/dashkit/covers/team-cover.jpg -------------------------------------------------------------------------------- /client/src/images/dashkit/masks/avatar-group-hover-last.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /client/src/images/dashkit/masks/avatar-group-hover.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /client/src/images/dashkit/masks/avatar-group.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /client/src/images/dashkit/masks/avatar-status.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /client/src/images/dashkit/masks/icon-status.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /client/src/images/icons/arrows.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /client/src/images/icons/capacity.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /client/src/images/icons/close.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/images/icons/cup.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /client/src/images/icons/loader.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/images/icons/processing.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /client/src/images/icons/table-cup.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /client/src/images/icons/tap.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 15 | 16 | 17 | 23 | 29 | 30 | 31 | 37 | 38 | 39 | 45 | 46 | 47 | 53 | 54 | 55 | 61 | 62 | 63 | 69 | 70 | -------------------------------------------------------------------------------- /client/src/images/icons/timer.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /client/src/images/icons/user.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/images/share-facebook-2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/images/share-facebook.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/images/share-twitter.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import { BrowserRouter } from "react-router-dom"; 4 | // import * as Sentry from "@sentry/react"; 5 | // import { Integrations } from "@sentry/tracing"; 6 | 7 | import "styles/index.scss"; 8 | 9 | import App from "./app"; 10 | import { WalletProvider } from "providers/wallet"; 11 | import { TransactionsProvider } from "providers/transactions"; 12 | import { GameStateProvider } from "providers/game"; 13 | import { ServerProvider } from "providers/server"; 14 | import { RpcProvider } from "providers/rpc"; 15 | import { SlotProvider } from "providers/slot"; 16 | import { AccountsProvider } from "providers/accounts"; 17 | import { TorusProvider } from "providers/torus"; 18 | import { ConfigProvider } from "providers/config"; 19 | 20 | // if (process.env.NODE_ENV === "production") { 21 | // Sentry.init({ 22 | // dsn: 23 | // "https://727cd3fff6f949449c1ce5030928e667@o434108.ingest.sentry.io/5411826", 24 | // integrations: [new Integrations.BrowserTracing()], 25 | // tracesSampleRate: 1.0, 26 | // }); 27 | // } 28 | 29 | ReactDOM.render( 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | , 51 | document.getElementById("root") 52 | ); 53 | -------------------------------------------------------------------------------- /client/src/pages/GamePage.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Redirect, useLocation } from "react-router-dom"; 3 | 4 | import { TransactionContainer } from "components/TxContainer"; 5 | import { TransactionModal } from "components/TxModal"; 6 | import { 7 | useTps, 8 | useCreatedCount, 9 | useAvgConfirmationTime, 10 | } from "providers/transactions"; 11 | import { useWalletState } from "providers/wallet"; 12 | import { useActiveUsers } from "providers/server/socket"; 13 | import { useGameState } from "providers/game"; 14 | 15 | export function GamePage() { 16 | const gameState = useGameState(); 17 | const gameStatus = gameState.status; 18 | const countdownStart = gameState.countdownStartTime; 19 | const showSetup = gameStatus === "setup"; 20 | const showStats = gameStatus === "play" || countdownStart !== undefined; 21 | const payer = useWalletState().wallet; 22 | const location = useLocation(); 23 | 24 | if (showSetup) { 25 | if (!payer) { 26 | return ; 27 | } else { 28 | return ; 29 | } 30 | } 31 | 32 | return ( 33 |
34 | {showStats && } 35 |
36 |
37 | 38 |
39 |
40 | 41 |
42 | ); 43 | } 44 | 45 | export function EmptyCard() { 46 | return ( 47 |
48 |
49 |
50 |
51 | ); 52 | } 53 | 54 | function Stats() { 55 | const createdCount = useCreatedCount(); 56 | const avgConfTime = useAvgConfirmationTime().toFixed(2); 57 | const tps = useTps(); 58 | const activeUsers = useActiveUsers(); 59 | 60 | return ( 61 |
62 | 63 | 68 | 69 | 70 |
71 | ); 72 | } 73 | 74 | type StatProps = { 75 | label: React.ReactNode; 76 | value: React.ReactNode; 77 | icon: string; 78 | }; 79 | function StatCard({ label, value, icon }: StatProps) { 80 | return ( 81 |
82 |
83 |
84 |
85 |
86 |
{label}
87 | {value} 88 |
89 |
90 | 91 |
92 |
93 |
94 |
95 |
96 | ); 97 | } 98 | -------------------------------------------------------------------------------- /client/src/pages/HomePage.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import graphic from "images/graphic.svg"; 4 | import breakSvg from "images/break.svg"; 5 | import solanaSvg from "images/solana.svg"; 6 | import { Link } from "react-router-dom"; 7 | 8 | export class HomePage extends React.Component { 9 | render() { 10 | return ( 11 |
12 |
13 |
14 |
15 | break 20 | solana 21 |
22 | 23 |
24 |

25 | Solana is the world’s most performant blockchain — currently, it 26 | can handle 50,000 transactions per second with 400 27 | millisecond block times. 28 |

29 |

30 | To see just how fast it is, you're invited to come and Break 31 | Solana. During the next fifteen seconds, send as many 32 | transactions as you can by mashing your keyboard. 33 |

34 |
35 |
36 | 40 | Play the game 41 | 42 |
43 |
44 |
45 | abstract graphic 46 |
47 | ); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /client/src/pages/SlotsPage.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { useSlotTiming } from "providers/slot"; 4 | import { SlotTableRow } from "components/SlotTableRow"; 5 | 6 | export function SlotsPage() { 7 | return ( 8 |
9 |
10 |
11 |
12 |
13 |
14 | Live Slot Stats 15 |
16 |
17 | 18 |
19 |
20 |
21 |
22 | ); 23 | } 24 | 25 | function SlotTable() { 26 | const slotTiming = useSlotTiming(); 27 | let min = Number.MAX_SAFE_INTEGER, 28 | max = 0; 29 | for (const slot of slotTiming.current.keys()) { 30 | min = Math.min(min, slot); 31 | max = Math.max(max, slot); 32 | } 33 | 34 | const slots = []; 35 | for (let i = min; i <= max; i++) { 36 | slots.push(i); 37 | } 38 | 39 | return ( 40 |
41 |
42 |
43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | {slots.map((slot) => ( 63 | 68 | ))} 69 | 70 |
LeaderSlot + ParentTx CountTx Success %Tx EntriesAvg Tx Per EntryMax Tx Per EntryFirst ShredShreds FullBank CreatedBank Frozen / DeadConfirmedRooted
71 |
72 |
73 |
74 | ); 75 | } 76 | -------------------------------------------------------------------------------- /client/src/pages/StartPage.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { useWalletState } from "providers/wallet"; 4 | import { Redirect, useLocation } from "react-router-dom"; 5 | import { PaymentCard } from "components/PaymentCard"; 6 | 7 | export function StartPage() { 8 | const payer = useWalletState().wallet; 9 | const location = useLocation(); 10 | 11 | if (!payer) { 12 | return ; 13 | } 14 | 15 | return ( 16 |
17 |
18 |
19 | 20 |
21 |
22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /client/src/providers/config.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { TrackedCommitment } from "./transactions"; 3 | 4 | export interface ClientConfig { 5 | computeUnitPrice?: number; 6 | parallelization: number; 7 | trackedCommitment: TrackedCommitment; 8 | showDebugTable: boolean; 9 | countdownSeconds: number; 10 | retryTransactionEnabled: boolean; 11 | autoSendTransactions: boolean; 12 | useTpu: boolean; 13 | rpcUrl?: string; 14 | extraWriteAccount?: string; 15 | } 16 | 17 | const DEFAULT_CONFIG: ClientConfig = { 18 | parallelization: 4, 19 | trackedCommitment: "confirmed", 20 | showDebugTable: false, 21 | retryTransactionEnabled: false, 22 | autoSendTransactions: false, 23 | countdownSeconds: 15, 24 | useTpu: false, 25 | }; 26 | 27 | type SetConfig = React.Dispatch>; 28 | const ConfigContext = React.createContext< 29 | [ClientConfig, SetConfig] | undefined 30 | >(undefined); 31 | 32 | type Props = { children: React.ReactNode }; 33 | export function ConfigProvider({ children }: Props) { 34 | const stateHook = React.useState(DEFAULT_CONFIG); 35 | return ( 36 | 37 | {children} 38 | 39 | ); 40 | } 41 | 42 | export function useClientConfig() { 43 | const context = React.useContext(ConfigContext); 44 | if (!context) { 45 | throw new Error(`useClientConfig must be used within a ConfigProvider`); 46 | } 47 | return context; 48 | } 49 | -------------------------------------------------------------------------------- /client/src/providers/game.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useHistory, useLocation } from "react-router-dom"; 3 | 4 | import { useServerConfig } from "providers/server/http"; 5 | import { useSocket } from "providers/server/socket"; 6 | import { useBlockhash } from "providers/rpc/blockhash"; 7 | import { useDispatch as useTransactionsDispatch } from "providers/transactions"; 8 | import { useAccountsState } from "./accounts"; 9 | import { useConnection } from "./rpc"; 10 | import { useClientConfig } from "./config"; 11 | 12 | type GameStatus = "loading" | "setup" | "play" | "finished"; 13 | export type LoadingPhase = 14 | | "blockhash" 15 | | "socket" 16 | | "config" 17 | | "costs" 18 | | "creating-accounts" 19 | | "complete"; 20 | 21 | export interface GameState { 22 | status: GameStatus; 23 | loadingPhase: LoadingPhase; 24 | countdownStartTime: number | undefined; 25 | prepareGame: () => void; 26 | resetGame: () => void; 27 | startGame: () => void; 28 | } 29 | 30 | const GameStateContext = React.createContext(undefined); 31 | 32 | type Props = { children: React.ReactNode }; 33 | export function GameStateProvider({ children }: Props) { 34 | const [status, setGameStatus] = React.useState("loading"); 35 | const [countdownStartTime, setCountdownStart] = React.useState(); 36 | const connection = useConnection(); 37 | const blockhash = useBlockhash(); 38 | const serverConfig = useServerConfig(); 39 | const [clientConfig] = useClientConfig(); 40 | const socket = useSocket(); 41 | const accountsState = useAccountsState(); 42 | const loadingPhase: LoadingPhase = React.useMemo(() => { 43 | if (!serverConfig) return "config"; 44 | if (!blockhash) return "blockhash"; 45 | if (!accountsState.creationCost) return "costs"; 46 | if (!socket) return "socket"; 47 | if (accountsState.status === "creating") return "creating-accounts"; 48 | return "complete"; 49 | }, [blockhash, serverConfig, socket, accountsState]); 50 | 51 | React.useEffect(() => { 52 | setGameStatus("loading"); 53 | setCountdownStart(undefined); 54 | }, [ 55 | connection, 56 | clientConfig.showDebugTable, 57 | clientConfig.parallelization, 58 | clientConfig.trackedCommitment, 59 | ]); 60 | 61 | React.useEffect(() => { 62 | if (status === "loading" && loadingPhase === "complete") { 63 | setGameStatus("setup"); 64 | } 65 | }, [status, loadingPhase]); 66 | 67 | const history = useHistory(); 68 | const location = useLocation(); 69 | const resultsTimerRef = React.useRef(); 70 | React.useEffect(() => { 71 | if (countdownStartTime !== undefined) { 72 | if (!resultsTimerRef.current) { 73 | resultsTimerRef.current = setTimeout(() => { 74 | setGameStatus("finished"); 75 | history.push({ ...location, pathname: "/results" }); 76 | }, clientConfig.countdownSeconds * 1000); 77 | } 78 | } else if (resultsTimerRef.current) { 79 | clearTimeout(resultsTimerRef.current); 80 | resultsTimerRef.current = undefined; 81 | } 82 | }, [countdownStartTime, history, location, clientConfig.countdownSeconds]); 83 | 84 | const startGame = React.useCallback(() => { 85 | setCountdownStart(performance.now()); 86 | }, []); 87 | 88 | const transactionsDispatch = useTransactionsDispatch(); 89 | const resetGame = React.useCallback(() => { 90 | setGameStatus("setup"); 91 | setCountdownStart(undefined); 92 | transactionsDispatch({ type: "reset" }); 93 | accountsState.deactivate(); 94 | history.push({ ...location, pathname: "/start" }); 95 | }, [accountsState, history, location, transactionsDispatch]); 96 | 97 | const prepareGame = React.useCallback(() => { 98 | setGameStatus("play"); 99 | accountsState.createAccounts(); 100 | history.push({ ...location, pathname: "/game" }); 101 | }, [accountsState, history, location]); 102 | 103 | const accountsStatus = accountsState.status; 104 | React.useEffect(() => { 105 | const shouldReset = status === "play" && accountsStatus === "inactive"; 106 | if (shouldReset) { 107 | resetGame(); 108 | } 109 | }, [status, accountsStatus, resetGame]); 110 | 111 | const gameState: GameState = React.useMemo( 112 | () => ({ 113 | status, 114 | loadingPhase, 115 | countdownStartTime, 116 | resetGame, 117 | prepareGame, 118 | startGame, 119 | }), 120 | [ 121 | status, 122 | loadingPhase, 123 | countdownStartTime, 124 | prepareGame, 125 | startGame, 126 | resetGame, 127 | ] 128 | ); 129 | 130 | return ( 131 | 132 | {children} 133 | 134 | ); 135 | } 136 | 137 | export function useGameState() { 138 | const context = React.useContext(GameStateContext); 139 | if (!context) { 140 | throw new Error(`useGameState must be used within a GameStateProvider`); 141 | } 142 | return context; 143 | } 144 | -------------------------------------------------------------------------------- /client/src/providers/rpc/balance.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { AccountInfo } from "@solana/web3.js"; 3 | 4 | import { useWalletState } from "providers/wallet"; 5 | import { useConnection } from "providers/rpc"; 6 | import { getFeePayers, reportError } from "utils"; 7 | import { useClientConfig } from "providers/config"; 8 | 9 | type Balance = number | "loading"; 10 | 11 | interface State { 12 | payer: Balance; 13 | feePayers: Array; 14 | } 15 | 16 | const StateContext = React.createContext(undefined); 17 | 18 | type Props = { children: React.ReactNode }; 19 | export function BalanceProvider({ children }: Props) { 20 | const [balance, setBalance] = React.useState("loading"); 21 | const [{ parallelization }] = useClientConfig(); 22 | const [feePayerBalances, setFeePayerBalances] = React.useState( 23 | Array(parallelization).fill(0) 24 | ); 25 | const payer = useWalletState().wallet; 26 | const connection = useConnection(); 27 | 28 | const refreshBalance = React.useCallback(() => { 29 | if (payer === undefined || connection === undefined) { 30 | setBalance("loading"); 31 | return; 32 | } 33 | 34 | (async () => { 35 | try { 36 | const balance = await connection.getBalance(payer.publicKey); 37 | setBalance(balance); 38 | } catch (err) { 39 | reportError(err, "Failed to refresh balance"); 40 | } 41 | })(); 42 | }, [payer, connection]); 43 | 44 | React.useEffect(() => { 45 | refreshBalance(); 46 | const onChange = () => { 47 | if (document.visibilityState !== "visible") return; 48 | refreshBalance(); 49 | }; 50 | 51 | document.addEventListener("visibilitychange", onChange); 52 | return () => document.removeEventListener("visibilitychange", onChange); 53 | }, [refreshBalance]); 54 | 55 | React.useEffect(() => { 56 | if (!payer || !connection) return; 57 | const subscription = connection.onAccountChange( 58 | payer.publicKey, 59 | (accountInfo: AccountInfo) => setBalance(accountInfo.lamports) 60 | ); 61 | 62 | return () => { 63 | connection.removeAccountChangeListener(subscription); 64 | }; 65 | }, [payer, connection]); 66 | 67 | const feePayers = React.useMemo( 68 | () => getFeePayers(parallelization), 69 | [parallelization] 70 | ); 71 | const feePayerCounter = React.useRef(0); 72 | React.useEffect(() => { 73 | if (!connection) return; 74 | feePayerCounter.current++; 75 | const currentCounter = feePayerCounter.current; 76 | 77 | (async () => { 78 | const balances = await Promise.all( 79 | feePayers.map((feePayer) => { 80 | return connection.getBalance(feePayer.publicKey); 81 | }) 82 | ); 83 | if (feePayerCounter.current === currentCounter) { 84 | setFeePayerBalances(balances); 85 | } 86 | })(); 87 | 88 | const subscriptions = feePayers.map((feePayer, index) => { 89 | return connection.onAccountChange( 90 | feePayer.publicKey, 91 | (accountInfo: AccountInfo) => { 92 | setFeePayerBalances((balances) => { 93 | const copy = [...balances]; 94 | copy[index] = accountInfo.lamports; 95 | return copy; 96 | }); 97 | } 98 | ); 99 | }); 100 | 101 | return () => { 102 | subscriptions.forEach((subscription) => { 103 | connection.removeAccountChangeListener(subscription); 104 | }); 105 | }; 106 | }, [connection, feePayers]); 107 | 108 | const state = React.useMemo( 109 | () => ({ 110 | payer: balance, 111 | feePayers: feePayerBalances, 112 | }), 113 | [balance, feePayerBalances] 114 | ); 115 | 116 | return ( 117 | {children} 118 | ); 119 | } 120 | 121 | export function useBalanceState(): State { 122 | const state = React.useContext(StateContext); 123 | if (state === undefined) { 124 | throw new Error(`useBalanceState must be used within a BalanceProvider`); 125 | } 126 | return state; 127 | } 128 | -------------------------------------------------------------------------------- /client/src/providers/rpc/blockhash.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Blockhash, Commitment, Connection } from "@solana/web3.js"; 3 | import { sleep, reportError } from "utils"; 4 | import { useConnection } from "."; 5 | 6 | const POLL_INTERVAL_MS = 2000; 7 | 8 | export enum ActionType { 9 | Start, 10 | Stop, 11 | Update, 12 | } 13 | 14 | interface Stop { 15 | type: ActionType.Stop; 16 | } 17 | 18 | interface Update { 19 | type: ActionType.Update; 20 | blockhash: Blockhash; 21 | } 22 | 23 | interface State { 24 | blockhash?: Blockhash; 25 | } 26 | 27 | type Action = Stop | Update; 28 | type Dispatch = (action: Action) => void; 29 | 30 | function reducer(state: State, action: Action): State { 31 | switch (action.type) { 32 | case ActionType.Stop: { 33 | return {}; 34 | } 35 | case ActionType.Update: { 36 | return Object.assign({}, state, { 37 | blockhash: action.blockhash, 38 | }); 39 | } 40 | } 41 | } 42 | 43 | const StateContext = React.createContext(undefined); 44 | const DispatchContext = React.createContext(undefined); 45 | 46 | type BlockhashProviderProps = { children: React.ReactNode }; 47 | export function BlockhashProvider({ children }: BlockhashProviderProps) { 48 | const [state, dispatch] = React.useReducer(reducer, {}); 49 | const connection = useConnection(); 50 | const connectionRef = React.useRef(connection); 51 | const refreshingRef = React.useRef(false); 52 | 53 | React.useEffect(() => { 54 | if (connection === undefined) return; 55 | 56 | connectionRef.current = connection; 57 | refresh(dispatch, connectionRef, refreshingRef); 58 | const timerId = window.setInterval( 59 | () => refresh(dispatch, connectionRef, refreshingRef), 60 | POLL_INTERVAL_MS 61 | ); 62 | 63 | return () => { 64 | clearInterval(timerId); 65 | dispatch({ type: ActionType.Stop }); 66 | }; 67 | }, [connection]); 68 | 69 | return ( 70 | 71 | 72 | {children} 73 | 74 | 75 | ); 76 | } 77 | 78 | export function useBlockhash() { 79 | const state = React.useContext(StateContext); 80 | if (!state) { 81 | throw new Error(`useBlockhash must be used within a BlockhashProvider`); 82 | } 83 | 84 | return state.blockhash; 85 | } 86 | 87 | async function nodeProgress( 88 | connection: Connection, 89 | commitment: Commitment 90 | ): Promise<{ blockhash: Blockhash; slot: number }> { 91 | const [{ blockhash }, slot] = await Promise.all([ 92 | connection.getLatestBlockhash(commitment), 93 | connection.getSlot(commitment), 94 | ]); 95 | 96 | return { blockhash, slot }; 97 | } 98 | 99 | async function refresh( 100 | dispatch: Dispatch, 101 | connectionRef: React.MutableRefObject, 102 | refreshingRef: React.MutableRefObject 103 | ) { 104 | let blockhash = undefined; 105 | const connection = connectionRef.current; 106 | if (connection === undefined) return; 107 | 108 | if (refreshingRef.current) return; 109 | refreshingRef.current = true; 110 | 111 | let reported = false; 112 | while (blockhash === undefined && connection === connectionRef.current) { 113 | try { 114 | const processedProgress = await nodeProgress(connection, "processed"); 115 | const confirmedProgress = await nodeProgress(connection, "confirmed"); 116 | const finalizedProgress = await nodeProgress(connection, "finalized"); 117 | console.log( 118 | `[${processedProgress.slot}, ${confirmedProgress.slot}, ${ 119 | finalizedProgress.slot 120 | }], [${processedProgress.blockhash.slice( 121 | 0, 122 | 5 123 | )}, ${confirmedProgress.blockhash.slice( 124 | 0, 125 | 5 126 | )}, ${finalizedProgress.blockhash.slice(0, 5)}]` 127 | ); 128 | blockhash = finalizedProgress.blockhash; 129 | dispatch({ type: ActionType.Update, blockhash }); 130 | } catch (err) { 131 | if (!reported) reportError(err, "Failed to refresh blockhash"); 132 | reported = true; 133 | await sleep(1000); 134 | } 135 | } 136 | 137 | refreshingRef.current = false; 138 | } 139 | -------------------------------------------------------------------------------- /client/src/providers/rpc/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Connection } from "@solana/web3.js"; 3 | import { useServerConfig } from "providers/server/http"; 4 | import { BlockhashProvider } from "./blockhash"; 5 | import { BalanceProvider } from "./balance"; 6 | 7 | type SetUrl = (url: string) => void; 8 | type State = [string | undefined, SetUrl]; 9 | 10 | type ConnectionState = { 11 | connection?: Connection; 12 | }; 13 | 14 | const StateContext = React.createContext(undefined); 15 | const ConnectionContext = React.createContext( 16 | undefined 17 | ); 18 | 19 | type ProviderProps = { children: React.ReactNode }; 20 | export function RpcProvider({ children }: ProviderProps) { 21 | const state = React.useState(); 22 | const [rpcUrl, setRpcUrl] = state; 23 | 24 | // Reset rpc url whenever config is fetched 25 | const configRpcUrl = useServerConfig()?.rpcUrl; 26 | React.useEffect(() => { 27 | setRpcUrl(configRpcUrl); 28 | }, [configRpcUrl, setRpcUrl]); 29 | 30 | const connection: ConnectionState = React.useMemo(() => { 31 | if (rpcUrl === undefined) return {}; 32 | try { 33 | const url = new URL(rpcUrl).toString(); 34 | return { connection: new Connection(url, "confirmed") }; 35 | } catch (err) { 36 | console.error(err); 37 | return {}; 38 | } 39 | }, [rpcUrl]); 40 | 41 | return ( 42 | 43 | 44 | 45 | {children} 46 | 47 | 48 | 49 | ); 50 | } 51 | 52 | export function useRpcUrlState(): State { 53 | const state = React.useContext(StateContext); 54 | if (state === undefined) { 55 | throw new Error(`useRpcUrlState must be used within a RpcProvider`); 56 | } 57 | return state; 58 | } 59 | 60 | export function useConnection(): Connection | undefined { 61 | const state = React.useContext(ConnectionContext); 62 | if (state === undefined) { 63 | throw new Error(`useConnection must be used within a RpcProvider`); 64 | } 65 | return state.connection; 66 | } 67 | -------------------------------------------------------------------------------- /client/src/providers/server/http/config.ts: -------------------------------------------------------------------------------- 1 | import { PublicKey, Cluster } from "@solana/web3.js"; 2 | 3 | export interface Config { 4 | cluster: Cluster | undefined; 5 | rpcUrl: string; 6 | programId: PublicKey; 7 | airdropEnabled: boolean; 8 | } 9 | 10 | function stringToCluster(str: string | undefined): Cluster | undefined { 11 | switch (str) { 12 | case "devnet": 13 | case "testnet": 14 | case "mainnet-beta": { 15 | return str; 16 | } 17 | default: 18 | return undefined; 19 | } 20 | } 21 | 22 | export function configFromInit(response: any): Config { 23 | const cluster = stringToCluster(response.cluster); 24 | return { 25 | cluster, 26 | rpcUrl: response.clusterUrl, 27 | programId: new PublicKey(response.programId), 28 | airdropEnabled: !response.paymentRequired, 29 | }; 30 | } 31 | -------------------------------------------------------------------------------- /client/src/providers/server/http/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Config as ServerConfig } from "./config"; 3 | import { useServer } from "providers/server"; 4 | import { fetchWithRetry } from "./request"; 5 | import { Connection } from "@solana/web3.js"; 6 | 7 | export enum ConfigStatus { 8 | Initialized, 9 | Fetching, 10 | Ready, 11 | Failure, 12 | } 13 | 14 | interface State { 15 | status: ConfigStatus; 16 | config?: ServerConfig; 17 | connection?: Connection; 18 | } 19 | 20 | interface Initialized { 21 | status: ConfigStatus.Initialized; 22 | config: ServerConfig; 23 | } 24 | 25 | interface Fetching { 26 | status: ConfigStatus.Fetching; 27 | } 28 | 29 | interface Ready { 30 | status: ConfigStatus.Ready; 31 | } 32 | 33 | interface Failure { 34 | status: ConfigStatus.Failure; 35 | config?: undefined; 36 | } 37 | 38 | export type Action = Initialized | Fetching | Ready | Failure; 39 | export type Dispatch = (action: Action) => void; 40 | 41 | function configReducer(state: State, action: Action): State { 42 | switch (action.status) { 43 | case ConfigStatus.Ready: 44 | case ConfigStatus.Initialized: { 45 | return { ...state, ...action }; 46 | } 47 | case ConfigStatus.Failure: { 48 | if (state.status === ConfigStatus.Fetching) { 49 | return { ...state, ...action }; 50 | } else { 51 | return state; 52 | } 53 | } 54 | case ConfigStatus.Fetching: { 55 | return { 56 | ...state, 57 | ...action, 58 | }; 59 | } 60 | } 61 | } 62 | 63 | const StateContext = React.createContext(undefined); 64 | const RefContext = React.createContext< 65 | React.MutableRefObject | undefined 66 | >(undefined); 67 | const DispatchContext = React.createContext(undefined); 68 | 69 | type ApiProviderProps = { children: React.ReactNode }; 70 | export function HttpProvider({ children }: ApiProviderProps) { 71 | const [state, dispatch] = React.useReducer(configReducer, { 72 | status: ConfigStatus.Fetching, 73 | }); 74 | 75 | const { httpUrl } = useServer(); 76 | const httpUrlRef = React.useRef(httpUrl); 77 | React.useEffect(() => { 78 | httpUrlRef.current = httpUrl; 79 | initConfig(dispatch, httpUrlRef); 80 | }, [httpUrl]); 81 | 82 | React.useEffect(() => { 83 | httpUrlRef.current = httpUrl; 84 | }, [httpUrl]); 85 | 86 | return ( 87 | 88 | 89 | {children} 90 | 91 | 92 | ); 93 | } 94 | 95 | async function initConfig( 96 | dispatch: Dispatch, 97 | httpUrlRef: React.MutableRefObject 98 | ): Promise { 99 | return fetchWithRetry(dispatch, httpUrlRef); 100 | } 101 | 102 | export function useServerConfig() { 103 | const context = React.useContext(StateContext); 104 | if (!context) { 105 | throw new Error(`useServerConfig must be used within a ApiProvider`); 106 | } 107 | return context.config; 108 | } 109 | 110 | export function useIsFetching() { 111 | const context = React.useContext(StateContext); 112 | if (!context) { 113 | throw new Error(`useIsFetching must be used within a ApiProvider`); 114 | } 115 | return context.status === ConfigStatus.Fetching; 116 | } 117 | 118 | export function useClusterParam(): string { 119 | const context = React.useContext(StateContext); 120 | if (!context) { 121 | throw new Error(`useClusterParam must be used within a ApiProvider`); 122 | } 123 | const cluster = context.config?.cluster; 124 | const rpcUrl = context.config?.rpcUrl; 125 | if (!cluster && rpcUrl) { 126 | return `cluster=custom&customUrl=${rpcUrl}`; 127 | } else if (cluster && cluster !== "mainnet-beta") { 128 | return `cluster=${cluster}`; 129 | } else { 130 | return ""; 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /client/src/providers/server/http/request.tsx: -------------------------------------------------------------------------------- 1 | import { configFromInit } from "./config"; 2 | import { sleep, reportError } from "utils"; 3 | import { Action, Dispatch, ConfigStatus } from "./index"; 4 | 5 | export async function fetchWithRetry( 6 | dispatch: Dispatch, 7 | httpUrlRef: React.MutableRefObject 8 | ) { 9 | dispatch({ 10 | status: ConfigStatus.Fetching, 11 | }); 12 | 13 | const httpUrl = httpUrlRef.current; 14 | while (httpUrl === httpUrlRef.current) { 15 | let response: Action | "retry" = await fetchInit(httpUrl); 16 | if (httpUrl !== httpUrlRef.current) break; 17 | if (response === "retry") { 18 | await sleep(2000); 19 | } else { 20 | dispatch(response); 21 | break; 22 | } 23 | } 24 | } 25 | 26 | async function fetchInit(httpUrl: string): Promise { 27 | try { 28 | const body = JSON.stringify({}); 29 | const response = await fetch( 30 | new Request(httpUrl + "/init", { 31 | method: "POST", 32 | headers: { 33 | "Content-Type": "application/json", 34 | }, 35 | body, 36 | }) 37 | ); 38 | const data = await response.json(); 39 | if (!("clusterUrl" in data) || !("programId" in data)) { 40 | throw new Error("Received invalid response"); 41 | } 42 | 43 | return { 44 | status: ConfigStatus.Initialized, 45 | config: configFromInit(data), 46 | }; 47 | } catch (err) { 48 | reportError(err, "/init failed"); 49 | return "retry"; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /client/src/providers/server/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Cluster } from "@solana/web3.js"; 3 | import { useLocation } from "react-router-dom"; 4 | import { isLocalHost } from "../../utils"; 5 | import { HttpProvider } from "./http"; 6 | import { SocketProvider } from "./socket"; 7 | 8 | type Server = Cluster | "custom"; 9 | export const DEFAULT_SERVER = isLocalHost() ? "custom" : "mainnet-beta"; 10 | export const SERVERS: Server[] = isLocalHost() 11 | ? ["custom"] 12 | : ["mainnet-beta", "testnet", "devnet", "custom"]; 13 | 14 | const DEFAULT_CUSTOM_URL = `http://${window.location.hostname}:${ 15 | process.env.PORT || 8080 16 | }`; 17 | 18 | export function serverName(server: Server): string { 19 | switch (server) { 20 | case "mainnet-beta": 21 | return "Mainnet Beta"; 22 | case "testnet": 23 | return "Testnet"; 24 | case "devnet": 25 | return "Devnet"; 26 | case "custom": 27 | return "Custom"; 28 | } 29 | } 30 | 31 | function parseQuery(query: URLSearchParams): Server { 32 | const clusterParam = query.get("cluster"); 33 | switch (clusterParam) { 34 | case "devnet": 35 | return "devnet"; 36 | case "testnet": 37 | return "testnet"; 38 | case "mainnet-beta": 39 | return "mainnet-beta"; 40 | case "custom": 41 | return "custom"; 42 | default: 43 | return DEFAULT_SERVER; 44 | } 45 | } 46 | 47 | type SetShowModal = React.Dispatch>; 48 | type ModalState = [boolean, SetShowModal]; 49 | const ModalContext = React.createContext(undefined); 50 | type SetCustomUrl = React.Dispatch>; 51 | type SetServer = React.Dispatch>; 52 | type ServerState = { 53 | server: Server; 54 | setServer: SetServer; 55 | customUrl: string; 56 | setCustomUrl: SetCustomUrl; 57 | }; 58 | const ServerContext = React.createContext(undefined); 59 | 60 | type ProviderProps = { children: React.ReactNode }; 61 | export function ServerProvider({ children }: ProviderProps) { 62 | const query = new URLSearchParams(useLocation().search); 63 | const serverParam = parseQuery(query); 64 | const [server, setServer] = React.useState(serverParam); 65 | const [customUrl, setCustomUrl] = React.useState(DEFAULT_CUSTOM_URL); 66 | const [showModal, setShowModal] = React.useState(false); 67 | 68 | // Update state when query params change 69 | React.useEffect(() => { 70 | setServer(serverParam); 71 | }, [serverParam]); 72 | 73 | const modalState: ModalState = React.useMemo(() => { 74 | return [showModal, setShowModal]; 75 | }, [showModal]); 76 | 77 | return ( 78 | 81 | 82 | 83 | {children} 84 | 85 | 86 | 87 | ); 88 | } 89 | 90 | function getServerUrl(server: Server, customUrl: string) { 91 | switch (server) { 92 | case "custom": { 93 | return customUrl; 94 | } 95 | default: { 96 | const useHttp = isLocalHost(); 97 | let slug: string = server; 98 | if (server === "mainnet-beta") { 99 | slug = "mainnet"; 100 | } 101 | return `${ 102 | useHttp ? "http" : "https" 103 | }://break-solana-${slug}.herokuapp.com`; 104 | } 105 | } 106 | } 107 | 108 | export function useServer() { 109 | const context = React.useContext(ServerContext); 110 | if (!context) { 111 | throw new Error(`useServer must be used within a ServerProvider`); 112 | } 113 | const { server, customUrl } = context; 114 | const httpUrl = getServerUrl(server, customUrl); 115 | const webSocketUrl = httpUrl.replace("http", "ws"); 116 | 117 | return { 118 | server, 119 | httpUrl, 120 | webSocketUrl, 121 | name: serverName(server), 122 | }; 123 | } 124 | 125 | export function useCustomUrl(): [string, SetCustomUrl] { 126 | const context = React.useContext(ServerContext); 127 | if (!context) { 128 | throw new Error(`useCustomUrl must be used within a ServerProvider`); 129 | } 130 | return [context.customUrl, context.setCustomUrl]; 131 | } 132 | 133 | export function useClusterModal() { 134 | const context = React.useContext(ModalContext); 135 | if (!context) { 136 | throw new Error(`useClusterModal must be used within a ServerProvider`); 137 | } 138 | return context; 139 | } 140 | -------------------------------------------------------------------------------- /client/src/providers/server/socket.tsx: -------------------------------------------------------------------------------- 1 | import { useClientConfig } from "providers/config"; 2 | import * as React from "react"; 3 | import { useServer } from "."; 4 | 5 | type SetSocket = React.Dispatch>; 6 | const SocketContext = React.createContext(undefined); 7 | 8 | const FailureCallbackContext = React.createContext< 9 | React.MutableRefObject | undefined 10 | >(undefined); 11 | 12 | type SetActiveUsers = React.Dispatch>; 13 | const ActiveUsersContext = React.createContext(undefined); 14 | 15 | const SWITCH_URL_CODE = 4444; 16 | 17 | type ServerSocket = { 18 | socket: WebSocket; 19 | id: number; 20 | }; 21 | 22 | let socketCounter = 0; 23 | 24 | type FailureCallback = (signature: string, reason: string) => void; 25 | 26 | type SocketProviderProps = { children: React.ReactNode }; 27 | export function SocketProvider({ children }: SocketProviderProps) { 28 | let [socket, setSocket] = React.useState(undefined); 29 | let failureCallbackRef = React.useRef(() => {}); 30 | let [activeUsers, setActiveUsers] = React.useState(1); 31 | let [{ useTpu, rpcUrl }] = useClientConfig(); 32 | 33 | const { webSocketUrl } = useServer(); 34 | React.useEffect(() => { 35 | newSocket(webSocketUrl, setSocket, setActiveUsers, failureCallbackRef); 36 | }, [webSocketUrl]); 37 | 38 | React.useEffect(() => { 39 | if (socket) { 40 | socket.socket.send(useTpu ? "tpu" : "rpc"); 41 | } 42 | }, [socket, useTpu]); 43 | 44 | React.useEffect(() => { 45 | if (socket && rpcUrl) { 46 | socket.socket.send(rpcUrl); 47 | } 48 | }, [socket, rpcUrl]); 49 | 50 | return ( 51 | 52 | 53 | 54 | {children} 55 | 56 | 57 | 58 | ); 59 | } 60 | 61 | function newSocket( 62 | webSocketUrl: string, 63 | setSocket: SetSocket, 64 | setActiveUsers: SetActiveUsers, 65 | failureCallbackRef: React.MutableRefObject 66 | ): WebSocket | undefined { 67 | socketCounter++; 68 | const id = socketCounter; 69 | 70 | let socket: WebSocket; 71 | try { 72 | socket = new WebSocket(webSocketUrl); 73 | } catch (err) { 74 | return; 75 | } 76 | 77 | socket.onopen = () => 78 | setSocket((serverSocket) => { 79 | if (!serverSocket || serverSocket.id <= id) { 80 | if (serverSocket && serverSocket.socket.readyState === WebSocket.OPEN) { 81 | serverSocket.socket.close(SWITCH_URL_CODE); 82 | } 83 | return { socket, id }; 84 | } else { 85 | socket.close(SWITCH_URL_CODE); 86 | return serverSocket; 87 | } 88 | }); 89 | 90 | socket.onmessage = (e) => { 91 | const data = JSON.parse(e.data); 92 | if ("activeUsers" in data) { 93 | setActiveUsers(data.activeUsers); 94 | } 95 | 96 | if (data?.type === "failure") { 97 | let signature = data?.signature; 98 | let reason = data?.reason; 99 | if (typeof signature === "string" && typeof reason === "string") { 100 | failureCallbackRef.current(signature, reason); 101 | } 102 | } 103 | }; 104 | 105 | socket.onclose = async (event) => { 106 | setSocket((serverSocket) => { 107 | // Socket may have been updated already 108 | if (!serverSocket || serverSocket.id === id) { 109 | // Reconnect if close was not explicit 110 | if (event.code !== SWITCH_URL_CODE) { 111 | console.error("Socket closed, reconnecting..."); 112 | // TODO: Re-enable 113 | // reportError(new Error("Socket was closed"), "Socket closed"); 114 | setTimeout(() => { 115 | newSocket( 116 | webSocketUrl, 117 | setSocket, 118 | setActiveUsers, 119 | failureCallbackRef 120 | ); 121 | }, 5000); 122 | } 123 | return undefined; 124 | } 125 | return serverSocket; 126 | }); 127 | }; 128 | 129 | socket.onerror = async () => { 130 | socket.close(); 131 | }; 132 | 133 | return socket; 134 | } 135 | 136 | export function useSocket() { 137 | return React.useContext(SocketContext); 138 | } 139 | 140 | export function useActiveUsers() { 141 | const context = React.useContext(ActiveUsersContext); 142 | if (!context) { 143 | throw new Error(`useActiveUsers must be used within a SocketProvider`); 144 | } 145 | 146 | return context; 147 | } 148 | 149 | export function useFailureCallback() { 150 | const context = React.useContext(FailureCallbackContext); 151 | if (!context) { 152 | throw new Error(`useFailureCallback must be used within a SocketProvider`); 153 | } 154 | 155 | return context; 156 | } 157 | -------------------------------------------------------------------------------- /client/src/providers/transactions/confirmed.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { useDispatch, TrackedCommitment } from "./index"; 4 | import { useConnection } from "providers/rpc"; 5 | import * as Bytes from "utils/bytes"; 6 | import { AccountInfo } from "@solana/web3.js"; 7 | import { useAccountsState } from "providers/accounts"; 8 | import { useClientConfig } from "providers/config"; 9 | 10 | // Determine commitment levels to subscribe to. "singleGossip" is used 11 | // to stop tx send retries so it must be returned 12 | export const subscribedCommitments = ( 13 | trackedCommitment: TrackedCommitment, 14 | showDebugTable: boolean 15 | ): TrackedCommitment[] => { 16 | if (showDebugTable) return ["confirmed"]; 17 | switch (trackedCommitment) { 18 | case "processed": { 19 | return ["processed", "confirmed"]; 20 | } 21 | default: { 22 | return ["confirmed"]; 23 | } 24 | } 25 | }; 26 | 27 | type Props = { children: React.ReactNode }; 28 | export function ConfirmedHelper({ children }: Props) { 29 | const dispatch = useDispatch(); 30 | const connection = useConnection(); 31 | const accounts = useAccountsState().accounts; 32 | const [{ showDebugTable, trackedCommitment }] = useClientConfig(); 33 | 34 | React.useEffect(() => { 35 | if (connection === undefined || accounts === undefined) return; 36 | if (showDebugTable) return; 37 | 38 | const commitments = subscribedCommitments( 39 | trackedCommitment, 40 | showDebugTable 41 | ); 42 | const partitionCount = accounts.programAccounts.length; 43 | 44 | const accountSubscriptions = accounts.programAccounts.map( 45 | (account, partition) => 46 | commitments.map((commitment) => 47 | connection.onAccountChange( 48 | account, 49 | (accountInfo: AccountInfo, { slot }) => { 50 | const ids = new Set(Bytes.programDataToIds(accountInfo.data)); 51 | const activeIdPartition = { 52 | ids, 53 | partition, 54 | partitionCount, 55 | }; 56 | dispatch({ 57 | type: "update", 58 | activeIdPartition, 59 | commitment, 60 | estimatedSlot: slot, 61 | receivedAt: performance.now(), 62 | }); 63 | }, 64 | commitment 65 | ) 66 | ) 67 | ); 68 | 69 | return () => { 70 | accountSubscriptions.forEach((listeners) => { 71 | listeners.forEach((listener: any) => { 72 | connection.removeAccountChangeListener(listener); 73 | }); 74 | }); 75 | }; 76 | }, [dispatch, connection, accounts, showDebugTable, trackedCommitment]); 77 | 78 | return <>{children}; 79 | } 80 | -------------------------------------------------------------------------------- /client/src/providers/transactions/selected.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useTransactions, TransactionState } from "./index"; 3 | 4 | type SetSelected = React.Dispatch>; 5 | type SelectedState = [TransactionState | undefined, SetSelected]; 6 | export const SelectedContext = React.createContext( 7 | undefined 8 | ); 9 | type ProviderProps = { children: React.ReactNode }; 10 | export function SelectedTxProvider({ children }: ProviderProps) { 11 | const transactions = useTransactions(); 12 | const [signature, selectSignature] = React.useState( 13 | undefined 14 | ); 15 | const [transaction, selectTransaction] = React.useState< 16 | TransactionState | undefined 17 | >(undefined); 18 | 19 | React.useEffect(() => { 20 | selectTransaction( 21 | transactions.find((tx) => tx.details.signature === signature) 22 | ); 23 | }, [transactions, signature]); 24 | 25 | const selectedState: SelectedState = React.useMemo(() => { 26 | return [transaction, selectSignature]; 27 | }, [transaction]); 28 | 29 | return ( 30 | 31 | {children} 32 | 33 | ); 34 | } 35 | 36 | export function useSelectedTransaction() { 37 | const state = React.useContext(SelectedContext); 38 | if (!state) { 39 | throw new Error(`useSelectedTx must be used within a TransactionsProvider`); 40 | } 41 | return state[0]; 42 | } 43 | 44 | export function useSelectTransaction() { 45 | const state = React.useContext(SelectedContext); 46 | if (!state) { 47 | throw new Error(`useSelectTx must be used within a TransactionsProvider`); 48 | } 49 | return state[1]; 50 | } 51 | -------------------------------------------------------------------------------- /client/src/providers/transactions/tps.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useCreatedCount } from "./index"; 3 | 4 | const TPS_REFRESH_MS = 100; 5 | const TPS_LOOK_BACK = 10; 6 | 7 | export const TpsContext = React.createContext(undefined); 8 | type ProviderProps = { children: React.ReactNode }; 9 | export function TpsProvider({ children }: ProviderProps) { 10 | const [tps, setTps] = React.useState(0); 11 | const createdCount = useCreatedCount(); 12 | const createdCountRef = React.useRef(0); 13 | createdCountRef.current = createdCount; 14 | 15 | React.useEffect(() => { 16 | const recentCounts: number[] = []; 17 | const timerId = setInterval(() => { 18 | if (createdCountRef.current === 0) { 19 | recentCounts.splice(0); 20 | setTps(0); 21 | return; 22 | } 23 | 24 | recentCounts.push(createdCountRef.current); 25 | while (recentCounts.length - 1 > TPS_LOOK_BACK) { 26 | recentCounts.shift(); 27 | } 28 | 29 | const ticksElapsed = recentCounts.length - 1; 30 | if (ticksElapsed <= 0) return; 31 | 32 | const oldTxCount = recentCounts[0]; 33 | const latestTxCount = recentCounts[ticksElapsed]; 34 | const tps = 35 | (latestTxCount - oldTxCount) / ((TPS_REFRESH_MS / 1000) * ticksElapsed); 36 | setTps(Math.floor(tps)); 37 | }, TPS_REFRESH_MS); 38 | return () => { 39 | clearInterval(timerId); 40 | }; 41 | }, []); 42 | 43 | return {children}; 44 | } 45 | -------------------------------------------------------------------------------- /client/src/providers/wallet.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Keypair } from "@solana/web3.js"; 3 | import { getLocalStorageKeypair } from "utils"; 4 | import { useHistory, useLocation } from "react-router"; 5 | 6 | interface State { 7 | wallet?: Keypair; 8 | selectWallet: (wallet: Keypair | undefined) => void; 9 | } 10 | 11 | const StateContext = React.createContext(undefined); 12 | 13 | type Props = { children: React.ReactNode }; 14 | export function WalletProvider({ children }: Props) { 15 | const [wallet, setWallet] = React.useState(); 16 | 17 | const history = useHistory(); 18 | const location = useLocation(); 19 | const selectWallet = React.useCallback( 20 | (keypair: Keypair | undefined) => { 21 | setWallet(keypair); 22 | if (keypair === undefined) { 23 | history.push({ ...location, pathname: "/wallet" }); 24 | } else { 25 | history.push({ ...location, pathname: "/start" }); 26 | } 27 | }, 28 | [history, location] 29 | ); 30 | 31 | const state = React.useMemo( 32 | () => ({ 33 | wallet, 34 | selectWallet, 35 | }), 36 | [wallet, selectWallet] 37 | ); 38 | 39 | return ( 40 | {children} 41 | ); 42 | } 43 | 44 | export const LOCAL_WALLET = (() => { 45 | return getLocalStorageKeypair("paymentKey"); 46 | })(); 47 | 48 | export function useWalletState(): State { 49 | const state = React.useContext(StateContext); 50 | if (state === undefined) { 51 | throw new Error(`usePayerState must be used within a WalletProvider`); 52 | } 53 | return state; 54 | } 55 | -------------------------------------------------------------------------------- /client/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /client/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import "@testing-library/jest-dom/extend-expect"; 6 | -------------------------------------------------------------------------------- /client/src/styles/custom-variables.scss: -------------------------------------------------------------------------------- 1 | $path-to-img: "../../images/dashkit" !default; 2 | $path-to-fonts: "../../fonts" !default; 3 | 4 | $font-family-sans-serif: "Exo 2", sans-serif !default; 5 | $font-family-base: $font-family-sans-serif !default; 6 | 7 | $border-radius: 0.125rem !default; 8 | $gray-700: #888 !default; 9 | $gray-600-dark: #555 !default; 10 | $gray-700-dark: #444 !default; 11 | $gray-750-dark: #333 !default; 12 | $gray-800-dark: #222 !default; 13 | $gray-900-dark: #111 !default; 14 | $black-dark: #000000; 15 | 16 | $black: #000000; 17 | $white: #ffffff; 18 | $green: #00ffad; 19 | $pink: #ff00a8; 20 | $purple: #ac62dd; 21 | $red: #ea134d; 22 | $orange: #e8873f; 23 | 24 | $info: $purple; 25 | $danger: $red; 26 | $warning: $pink; 27 | $primary: $green; 28 | 29 | $card-border-radius: 0.25rem !default; 30 | $card-outline-color: $gray-600-dark !default; 31 | $spacer: 1rem !default; 32 | 33 | $modal-md: 400px !default; 34 | -------------------------------------------------------------------------------- /client/src/styles/custom.scss: -------------------------------------------------------------------------------- 1 | @import "custom/app"; 2 | @import "custom/game"; 3 | @import "custom/home"; 4 | @import "custom/header"; 5 | @import "custom/payment-card"; 6 | @import "custom/results"; 7 | @import "custom/sidebar"; 8 | @import "custom/tx-container"; 9 | @import "custom/tx-square"; 10 | 11 | .card-body, 12 | .card-header, 13 | .card-footer { 14 | padding: 1rem; 15 | } 16 | 17 | .card-header { 18 | height: 50px; 19 | } 20 | 21 | .card { 22 | margin-bottom: 0.75rem; 23 | } 24 | 25 | .header { 26 | margin-bottom: 1rem; 27 | } 28 | 29 | .header .header-body { 30 | padding: 1rem 0; 31 | } 32 | 33 | .sticky { 34 | position: sticky; 35 | top: 0px; 36 | border-top: 0px solid black !important; 37 | } 38 | 39 | .btn { 40 | letter-spacing: 0.1em; 41 | } 42 | 43 | .btn.input-group { 44 | input { 45 | cursor: pointer; 46 | 47 | &:focus { 48 | cursor: text; 49 | } 50 | } 51 | } 52 | 53 | .border-dark-purple { 54 | border-color: darken($purple, 20%); 55 | } 56 | 57 | .border-top-dark { 58 | border-top: 1px solid $dark; 59 | } 60 | 61 | .btn-pink { 62 | &.btn-secondary { 63 | border-color: darken($pink, 10%); 64 | } 65 | 66 | background-color: $pink; 67 | 68 | @include hover-focus { 69 | background-color: darken($pink, 10%); 70 | } 71 | } 72 | 73 | .cluster-modal { 74 | z-index: 3000; 75 | } 76 | 77 | .header { 78 | z-index: 2000; 79 | } 80 | 81 | .modal-backdrop { 82 | pointer-events: none; 83 | } 84 | 85 | .modal.show { 86 | display: block; 87 | } 88 | 89 | .b-white { 90 | background: $white; 91 | } 92 | 93 | .b-black { 94 | background: $black !important; 95 | } 96 | 97 | .c-pointer { 98 | cursor: pointer; 99 | } 100 | 101 | .touch-action-none { 102 | touch-action: none; 103 | } 104 | 105 | .popover-container { 106 | position: relative; 107 | 108 | .popover { 109 | max-width: fit-content; 110 | 111 | &.right { 112 | left: -1.5rem; 113 | } 114 | 115 | &.bs-popover-bottom { 116 | background: $gray-750-dark; 117 | top: 1.5rem; 118 | } 119 | 120 | .popover-body { 121 | color: white; 122 | min-width: max-content; 123 | } 124 | 125 | .arrow::after { 126 | border-top-color: $gray-750-dark; 127 | border-bottom-color: $gray-750-dark; 128 | } 129 | } 130 | } 131 | 132 | .flex-basis-auto { 133 | flex-basis: auto !important; 134 | } 135 | 136 | .z-auto { 137 | z-index: auto !important; 138 | } 139 | 140 | .debug-row { 141 | cursor: pointer; 142 | 143 | &:hover { 144 | background: #111; 145 | } 146 | } 147 | 148 | .min-width-0 { 149 | min-width: 0; 150 | } 151 | -------------------------------------------------------------------------------- /client/src/styles/custom/_app.scss: -------------------------------------------------------------------------------- 1 | img.graphic { 2 | position: fixed; 3 | top: 0; 4 | left: 0; 5 | height: 100vh; 6 | z-index: -1; 7 | } 8 | -------------------------------------------------------------------------------- /client/src/styles/custom/_game.scss: -------------------------------------------------------------------------------- 1 | @include media-breakpoint-up(lg) { 2 | .stat-card { 3 | padding: 0px 10px; 4 | 5 | &:first-child { 6 | padding-left: 12px; 7 | } 8 | 9 | &:last-child { 10 | padding-right: 12px; 11 | } 12 | } 13 | } 14 | 15 | @include media-breakpoint-down(md) { 16 | .stat-card { 17 | &:nth-child(odd) { 18 | padding-right: 6px; 19 | } 20 | 21 | &:nth-child(even) { 22 | padding-left: 6px; 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /client/src/styles/custom/_header.scss: -------------------------------------------------------------------------------- 1 | .solana-header.header { 2 | margin-bottom: 0; 3 | } 4 | 5 | .solana-header .btn-group { 6 | pointer-events: none; 7 | cursor: default; 8 | 9 | &.countdown { 10 | min-width: 60px; 11 | } 12 | } 13 | 14 | .solana-header img.break { 15 | height: 40px; 16 | 17 | @include media-breakpoint-down(md) { 18 | height: 25px; 19 | } 20 | } 21 | 22 | .solana-header img.solana { 23 | height: 15px; 24 | 25 | @include media-breakpoint-down(md) { 26 | height: 10px; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /client/src/styles/custom/_home.scss: -------------------------------------------------------------------------------- 1 | .introduction { 2 | line-height: 3rem; 3 | font-weight: 100; 4 | font-size: 1.5rem; 5 | 6 | @include media-breakpoint-down(sm) { 7 | line-height: 2rem; 8 | font-size: 1.2rem; 9 | } 10 | } 11 | 12 | @include media-breakpoint-down(md) { 13 | .home-page { 14 | max-width: 768px; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /client/src/styles/custom/_payment-card.scss: -------------------------------------------------------------------------------- 1 | .qr-code { 2 | width: 250px; 3 | height: 250px; 4 | } 5 | -------------------------------------------------------------------------------- /client/src/styles/custom/_results.scss: -------------------------------------------------------------------------------- 1 | .stats { 2 | overflow-x: scroll; 3 | flex-wrap: nowrap; 4 | max-width: 800px; 5 | 6 | @include media-breakpoint-down(sm) { 7 | padding-bottom: 10px !important; 8 | } 9 | 10 | scrollbar-color: #2a2a2a transparent; 11 | scrollbar-width: thin; 12 | 13 | &::-webkit-scrollbar { 14 | height: 0.4rem; 15 | } 16 | 17 | &::-webkit-scrollbar-track { 18 | background: transparent; 19 | } 20 | 21 | &::-webkit-scrollbar-thumb { 22 | border-radius: 4px; 23 | background: #2a2a2a; 24 | } 25 | } 26 | 27 | .stat-circle { 28 | height: 200px; 29 | width: 200px; 30 | @include media-breakpoint-down(sm) { 31 | height: 175px; 32 | width: 175px; 33 | } 34 | } 35 | 36 | .results-summary h3 { 37 | line-height: 1.5rem; 38 | font-weight: 100; 39 | } 40 | 41 | .donut-content { 42 | &.allow-events { 43 | pointer-events: none; 44 | } 45 | 46 | position: absolute; 47 | top: 0px; 48 | left: 0px; 49 | right: 0px; 50 | bottom: 0px; 51 | display: flex; 52 | flex-direction: column; 53 | justify-content: center; 54 | align-items: center; 55 | padding: 30px; 56 | } 57 | -------------------------------------------------------------------------------- /client/src/styles/custom/_sidebar.scss: -------------------------------------------------------------------------------- 1 | .page-sidebar { 2 | min-width: 250px; 3 | max-width: 300px; 4 | width: 25vw; 5 | background-color: $gray-800-dark; 6 | border-right: 1px solid $purple; 7 | } 8 | 9 | .sidebar-body { 10 | margin: 1rem; 11 | } 12 | 13 | @media screen and (max-width: 768px) { 14 | .page-sidebar { 15 | display: none; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /client/src/styles/custom/_tx-container.scss: -------------------------------------------------------------------------------- 1 | .main { 2 | flex: 1; 3 | } 4 | 5 | .content { 6 | display: flex; 7 | flex-direction: column; 8 | height: 100%; 9 | } 10 | 11 | .debug-wrapper { 12 | overflow-y: auto; 13 | flex-grow: 1; 14 | height: 100px; 15 | 16 | outline: none; 17 | outline-color: transparent; 18 | 19 | scrollbar-color: rgba(0, 255, 173, 0.4) transparent; 20 | scrollbar-width: thin; 21 | 22 | &::-webkit-scrollbar { 23 | width: 0.5rem; 24 | } 25 | 26 | &::-webkit-scrollbar-track { 27 | background: transparent; 28 | } 29 | 30 | &::-webkit-scrollbar-thumb { 31 | background: rgba(0, 255, 173, 0.4); 32 | } 33 | } 34 | 35 | .tx-wrapper { 36 | min-height: 200px; 37 | 38 | h2 { 39 | line-height: 2rem; 40 | } 41 | 42 | .square-container { 43 | position: absolute; 44 | top: 0px; 45 | left: 0px; 46 | right: 0px; 47 | bottom: 0px; 48 | padding-right: 1rem; 49 | overflow-x: hidden; 50 | overflow-y: scroll; 51 | display: flex; 52 | flex-wrap: wrap; 53 | align-content: flex-start; 54 | 55 | outline: none; 56 | outline-color: transparent; 57 | 58 | scrollbar-color: rgba(0, 255, 173, 0.4) transparent; 59 | scrollbar-width: thin; 60 | 61 | &::-webkit-scrollbar { 62 | width: 0.5rem; 63 | } 64 | 65 | &::-webkit-scrollbar-track { 66 | background: transparent; 67 | } 68 | 69 | &::-webkit-scrollbar-thumb { 70 | background: rgba(0, 255, 173, 0.4); 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /client/src/styles/custom/_tx-square.scss: -------------------------------------------------------------------------------- 1 | .square { 2 | width: 0.8rem; 3 | height: 0.8rem; 4 | margin: 0.2rem; 5 | padding: 0; 6 | border-radius: 2px; 7 | font-size: 10px; 8 | 9 | &.legend { 10 | border-radius: 3px; 11 | width: 1.5rem; 12 | height: 1.5rem; 13 | margin: 0.2rem; 14 | } 15 | 16 | @include media-breakpoint-down(sm) { 17 | border-radius: 3px; 18 | width: 1.5rem; 19 | height: 1.5rem; 20 | margin: 0.2rem; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /client/src/styles/dashkit/_alert.scss: -------------------------------------------------------------------------------- 1 | // 2 | // alerts 3 | // Extended from Bootstrap 4 | // 5 | 6 | // 7 | // Bootstrap Overrides ===================================== 8 | // 9 | 10 | // Allow for a text-decoration since links are the same color as the alert text. 11 | 12 | .alert-link { 13 | text-decoration: $alert-link-text-decoration; 14 | } 15 | 16 | // Color variants 17 | // 18 | // Using Bootstrap's core alert-variant mixin to generate solid background color + yiq colorized text (and making close/links match those colors) 19 | 20 | @each $color, $value in $theme-colors { 21 | .alert-#{$color} { 22 | @include alert-variant(theme-color-level($color, $alert-bg-level), theme-color-level($color, $alert-border-level), color-yiq(theme-color-level($color, $alert-bg-level))); 23 | .close, 24 | .alert-link { 25 | color: color-yiq(theme-color-level($color, $alert-bg-level)); 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /client/src/styles/dashkit/_avatar.scss: -------------------------------------------------------------------------------- 1 | // 2 | // avatar.scss 3 | // Dashkit component 4 | // 5 | 6 | // General 7 | 8 | .avatar { 9 | position: relative; 10 | display: inline-block; 11 | width: $avatar-size-base; 12 | height: $avatar-size-base; 13 | font-size: $avatar-size-base / 3; 14 | 15 | // Loads mask images so they don't lag on hover 16 | 17 | &:after { 18 | content: ''; 19 | position: absolute; 20 | width: 0; 21 | height: 0; 22 | background-image: url(#{$path-to-img}/masks/avatar-status.svg), 23 | url(#{$path-to-img}/masks/avatar-group.svg), 24 | url(#{$path-to-img}/masks/avatar-group-hover.svg), 25 | url(#{$path-to-img}/masks/avatar-group-hover-last.svg); 26 | } 27 | } 28 | 29 | .avatar-img { 30 | width: 100%; 31 | height: 100%; 32 | object-fit: cover; 33 | } 34 | 35 | .avatar-title { 36 | display: flex; 37 | align-items: center; 38 | justify-content: center; 39 | width: 100%; 40 | height: 100%; 41 | line-height: 0; 42 | background-color: $avatar-title-bg; 43 | color: $avatar-title-color; 44 | } 45 | 46 | 47 | // Status 48 | 49 | .avatar-online, 50 | .avatar-offline { 51 | 52 | &::before { 53 | content: ''; 54 | position: absolute; 55 | bottom: 5%; 56 | right: 5%; 57 | width: 20%; 58 | height: 20%; 59 | border-radius: 50%; 60 | } 61 | 62 | .avatar-img { 63 | mask-image: url(#{$path-to-img}/masks/avatar-status.svg); 64 | mask-size: 100% 100%; 65 | } 66 | } 67 | 68 | .avatar-online::before { 69 | background-color: $success; 70 | } 71 | 72 | .avatar-offline::before { 73 | background-color: $gray-500; 74 | } 75 | 76 | 77 | // Sizing 78 | 79 | .avatar-xs { 80 | width: $avatar-size-xs; 81 | height: $avatar-size-xs; 82 | font-size: $avatar-size-xs / 3; 83 | } 84 | 85 | .avatar-sm { 86 | width: $avatar-size-sm; 87 | height: $avatar-size-sm; 88 | font-size: $avatar-size-sm / 3; 89 | } 90 | 91 | .avatar-lg { 92 | width: $avatar-size-lg; 93 | height: $avatar-size-lg; 94 | font-size: $avatar-size-lg / 3; 95 | } 96 | 97 | .avatar-xl { 98 | width: $avatar-size-xl; 99 | height: $avatar-size-xl; 100 | font-size: $avatar-size-xl / 3; 101 | } 102 | 103 | .avatar-xxl { 104 | width: $avatar-size-xl; 105 | height: $avatar-size-xl; 106 | font-size: $avatar-size-xl / 3; 107 | 108 | @include media-breakpoint-up(md) { 109 | width: $avatar-size-xxl; 110 | height: $avatar-size-xxl; 111 | font-size: $avatar-size-xxl / 3; 112 | } 113 | } 114 | 115 | 116 | // Ratio 117 | 118 | .avatar.avatar-4by3 { 119 | width: $avatar-size-base * 4 / 3; 120 | } 121 | 122 | .avatar-xs.avatar-4by3 { 123 | width: $avatar-size-xs * 4 / 3; 124 | } 125 | 126 | .avatar-sm.avatar-4by3 { 127 | width: $avatar-size-sm * 4 / 3; 128 | } 129 | 130 | .avatar-lg.avatar-4by3 { 131 | width: $avatar-size-lg * 4 / 3; 132 | } 133 | 134 | .avatar-xl.avatar-4by3 { 135 | width: $avatar-size-xl * 4 / 3; 136 | } 137 | 138 | .avatar-xxl.avatar-4by3 { 139 | width: $avatar-size-xxl * 4 / 3; 140 | } 141 | 142 | 143 | // Group 144 | 145 | .avatar-group { 146 | display: inline-flex; 147 | 148 | // Shift every next avatar left 149 | 150 | .avatar + .avatar { 151 | margin-left: -$avatar-size-base / 4; 152 | } 153 | 154 | .avatar-xs + .avatar-xs { 155 | margin-left: -$avatar-size-xs / 4; 156 | } 157 | 158 | .avatar-sm + .avatar-sm { 159 | margin-left: -$avatar-size-sm / 4; 160 | } 161 | 162 | .avatar-lg + .avatar-lg { 163 | margin-left: -$avatar-size-lg / 4; 164 | } 165 | 166 | .avatar-xl + .avatar-xl { 167 | margin-left: -$avatar-size-xl / 4; 168 | } 169 | 170 | .avatar-xxl + .avatar-xxl { 171 | margin-left: -$avatar-size-xxl / 4; 172 | } 173 | 174 | // Add some spacing between avatars 175 | 176 | .avatar:not(:last-child) { 177 | mask-image: url(#{$path-to-img}/masks/avatar-group.svg); 178 | mask-size: 100% 100%; 179 | } 180 | 181 | // Bring an avatar to front on hover 182 | 183 | .avatar:hover { 184 | mask-image: none; 185 | z-index: 1; 186 | 187 | + .avatar { 188 | mask-image: url(#{$path-to-img}/masks/avatar-group-hover.svg); 189 | mask-size: 100% 100%; 190 | 191 | &:last-child { 192 | mask-image: url(#{$path-to-img}/masks/avatar-group-hover-last.svg); 193 | } 194 | } 195 | } 196 | 197 | } 198 | -------------------------------------------------------------------------------- /client/src/styles/dashkit/_badge.scss: -------------------------------------------------------------------------------- 1 | // 2 | // badge.scss 3 | // Extended from Bootstrap 4 | // 5 | 6 | // 7 | // Bootstrap Overrides ===================================== 8 | // 9 | 10 | .badge { 11 | vertical-align: middle; 12 | } 13 | 14 | // Quick fix for badges in buttons 15 | .btn .badge { 16 | top: -2px; 17 | } 18 | 19 | // 20 | // Dashkit ===================================== 21 | // 22 | 23 | // Creates the "soft" badge variant 24 | @each $color, $value in $theme-colors { 25 | .badge-soft-#{$color} { 26 | @include badge-variant-soft(theme-color-level($color, $badge-soft-bg-level), $value); 27 | } 28 | } -------------------------------------------------------------------------------- /client/src/styles/dashkit/_breadcrumb.scss: -------------------------------------------------------------------------------- 1 | // 2 | // breadcrumb.scss 3 | // Extended from Bootstrap 4 | // 5 | 6 | // 7 | // Bootstrap Overrides ===================================== 8 | // 9 | 10 | .breadcrumb-item { 11 | // The separator between breadcrumbs 12 | + .breadcrumb-item::before { 13 | width: .3rem; 14 | height: .6rem; 15 | margin-right: $breadcrumb-item-padding; 16 | -webkit-mask: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c3ZnIHdpZHRoPSIxMHB4IiBoZWlnaHQ9IjE2cHgiIHZpZXdCb3g9IjAgMCAxMCAxNiIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIj4gICAgICAgIDx0aXRsZT5TaGFwZTwvdGl0bGU+ICAgIDxkZXNjPkNyZWF0ZWQgd2l0aCBTa2V0Y2guPC9kZXNjPiAgICA8ZGVmcz48L2RlZnM+ICAgIDxnIGlkPSJQYWdlLTEiIHN0cm9rZT0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIxIiBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+ICAgICAgICA8ZyBpZD0iY2hldnJvbi1yaWdodCIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMi4wMDAwMDAsIDIuMDAwMDAwKSIgc3Ryb2tlPSIjMDAwMDAwIiBzdHJva2Utd2lkdGg9IjIuNSI+ICAgICAgICAgICAgPHBvbHlsaW5lIGlkPSJTaGFwZSIgcG9pbnRzPSIwIDEyIDYgNiAwIDAiPjwvcG9seWxpbmU+ICAgICAgICA8L2c+ICAgIDwvZz48L3N2Zz4=) no-repeat 50% 50%; 17 | mask: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c3ZnIHdpZHRoPSIxMHB4IiBoZWlnaHQ9IjE2cHgiIHZpZXdCb3g9IjAgMCAxMCAxNiIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIj4gICAgICAgIDx0aXRsZT5TaGFwZTwvdGl0bGU+ICAgIDxkZXNjPkNyZWF0ZWQgd2l0aCBTa2V0Y2guPC9kZXNjPiAgICA8ZGVmcz48L2RlZnM+ICAgIDxnIGlkPSJQYWdlLTEiIHN0cm9rZT0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIxIiBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+ICAgICAgICA8ZyBpZD0iY2hldnJvbi1yaWdodCIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMi4wMDAwMDAsIDIuMDAwMDAwKSIgc3Ryb2tlPSIjMDAwMDAwIiBzdHJva2Utd2lkdGg9IjIuNSI+ICAgICAgICAgICAgPHBvbHlsaW5lIGlkPSJTaGFwZSIgcG9pbnRzPSIwIDEyIDYgNiAwIDAiPjwvcG9seWxpbmU+ICAgICAgICA8L2c+ICAgIDwvZz48L3N2Zz4=) no-repeat 50% 50%; 18 | -webkit-mask-size: contain; 19 | mask-size: contain; 20 | background: $breadcrumb-divider-color; 21 | } 22 | } 23 | 24 | 25 | // 26 | // Dashkit ===================================== 27 | // 28 | 29 | // Small 30 | // 31 | // Reduces font size 32 | 33 | .breadcrumb-sm { 34 | font-size: $breadcrumb-font-size-sm; 35 | } 36 | 37 | 38 | // Overflow 39 | // 40 | // Allows the breadcrumb to be overflown horizontally 41 | 42 | .breadcrumb-overflow { 43 | display: flex; 44 | flex-direction: row; 45 | flex-wrap: nowrap; 46 | overflow-x: auto; 47 | 48 | &::-webkit-scrollbar { 49 | display: none; 50 | } 51 | } 52 | 53 | .breadcrumb-overflow .breadcrumb-item { 54 | white-space: nowrap; 55 | } -------------------------------------------------------------------------------- /client/src/styles/dashkit/_buttons.scss: -------------------------------------------------------------------------------- 1 | // 2 | // buttons.scss 3 | // Extended from Bootstrap 4 | // 5 | 6 | 7 | // 8 | // Dashkit =================================== 9 | // 10 | 11 | // Button white 12 | 13 | .btn-white { 14 | border-color: $gray-300; 15 | 16 | @include hover-focus { 17 | background-color: $gray-100; 18 | border-color: $gray-400; 19 | } 20 | } 21 | 22 | .btn-group-toggle .btn-white:not(:disabled):not(.disabled):active, 23 | .btn-group-toggle .btn-white:not(:disabled):not(.disabled).active { 24 | background-color: $input-bg; 25 | border-color: $input-focus-border-color; 26 | color: $primary; 27 | } 28 | 29 | .btn-group-toggle .btn-white:focus, 30 | .btn-group-toggle .btn-white.focus { 31 | box-shadow: none; 32 | } 33 | 34 | 35 | // Button outline secondary 36 | 37 | .btn-outline-secondary { 38 | &:not(:hover):not([aria-expanded="true"]):not([aria-pressed="true"]){ 39 | border-color: $gray-400; 40 | } 41 | } 42 | 43 | 44 | // Button rounded 45 | // 46 | // Creates circle button variations 47 | 48 | .btn-rounded-circle { 49 | width: calc(1em * #{$btn-line-height} + #{$input-btn-padding-y * 2 } + #{$btn-border-width} * 2); 50 | padding-left: 0; 51 | padding-right: 0; 52 | border-radius: 50%; 53 | } 54 | .btn-rounded-circle.btn-lg { 55 | width: calc(1em * #{$btn-line-height-lg} + #{$input-btn-padding-y-lg * 2 } + #{$btn-border-width} * 2); 56 | } 57 | .btn-rounded-circle.btn-sm { 58 | width: calc(1em * #{$btn-line-height-sm} + #{$input-btn-padding-y-sm * 2 } + #{$btn-border-width} * 2); 59 | } 60 | 61 | 62 | // Button group 63 | // 64 | // Prevent buttons from jittering on hover 65 | 66 | .btn-group .btn + .btn { 67 | margin-left: 0; 68 | } -------------------------------------------------------------------------------- /client/src/styles/dashkit/_chart.scss: -------------------------------------------------------------------------------- 1 | // 2 | // chart.scss 3 | // Dashkit component 4 | // 5 | 6 | // Chart 7 | // 8 | // General styles 9 | 10 | .chart { 11 | position: relative; 12 | height: $chart-height; 13 | } 14 | 15 | .chart.chart-appended { 16 | height: calc(#{$chart-height} - #{$chart-legend-height}); 17 | } 18 | 19 | .chart-sm { 20 | height: $chart-height-sm; 21 | } 22 | 23 | .chart-sm.chart-appended { 24 | height: calc(#{$chart-height-sm} - #{$chart-legend-height}); 25 | } 26 | 27 | 28 | // Sparkline 29 | 30 | .chart-sparkline { 31 | width: $chart-sparkline-width; 32 | height: $chart-sparkline-height; 33 | } 34 | 35 | 36 | // Legend 37 | // 38 | // Custom legend 39 | 40 | .chart-legend { 41 | display: flex; 42 | justify-content: center; 43 | margin-top: $chart-legend-margin-top; 44 | font-size: $chart-legend-font-size; 45 | text-align: center; 46 | color: $chart-legend-color; 47 | } 48 | 49 | .chart-legend-item { 50 | display: inline-flex; 51 | align-items: center; 52 | 53 | + .chart-legend-item { 54 | margin-left: 1rem; 55 | } 56 | } 57 | 58 | .chart-legend-indicator { 59 | display: inline-block; 60 | width: .5rem; 61 | height: .5rem; 62 | margin-right: 0.375rem; 63 | border-radius: 50%; 64 | } 65 | 66 | 67 | // Tooltip 68 | // 69 | // Custom tooltip 70 | 71 | #chart-tooltip { 72 | z-index: 0; 73 | } 74 | 75 | #chart-tooltip .arrow { 76 | top: 100%; 77 | left: 50%; 78 | transform: translateX(-50%) translateX(-.5rem); 79 | } 80 | -------------------------------------------------------------------------------- /client/src/styles/dashkit/_checklist.scss: -------------------------------------------------------------------------------- 1 | // 2 | // checklist.scss 3 | // Dashkit component 4 | // 5 | 6 | .checklist { 7 | outline: none; 8 | } 9 | 10 | .checklist-control { 11 | display: flex; 12 | flex-wrap: nowrap; 13 | outline: none; 14 | user-select: none; 15 | } 16 | 17 | .checklist-control .custom-control-input:checked ~ .custom-control-caption { 18 | text-decoration: line-through; 19 | color: $checklist-control-checked-color; 20 | } 21 | 22 | .checklist-control + .checklist-control { 23 | margin-top: $checklist-control-spacer; 24 | } 25 | 26 | .checklist-control:first-child[style*="display: none"] + .checklist-control { 27 | margin-top: 0; 28 | } 29 | 30 | .checklist-control.draggable-mirror { 31 | z-index: $zindex-fixed; 32 | } 33 | 34 | .checklist-control.draggable-source--is-dragging { 35 | opacity: .2; 36 | } 37 | -------------------------------------------------------------------------------- /client/src/styles/dashkit/_close.scss: -------------------------------------------------------------------------------- 1 | // 2 | // close.scss 3 | // Extended from Bootstrap 4 | // 5 | 6 | // Small 7 | 8 | .close-sm { 9 | font-size: $close-font-size-sm; 10 | } -------------------------------------------------------------------------------- /client/src/styles/dashkit/_code.scss: -------------------------------------------------------------------------------- 1 | // 2 | // code.scss 3 | // Extended from Bootstrap 4 | // 5 | 6 | // 7 | // Dashkit ================================= 8 | // 9 | 10 | // Highlight 11 | // 12 | // Hightlight.js overrides 13 | 14 | .highlight { 15 | padding: 0; 16 | } -------------------------------------------------------------------------------- /client/src/styles/dashkit/_comment.scss: -------------------------------------------------------------------------------- 1 | // 2 | // comment.scss 3 | // Dashkit component 4 | // 5 | 6 | // Comment 7 | // 8 | // General styles 9 | 10 | .comment { 11 | margin-bottom: $comment-margin-bottom; 12 | } 13 | 14 | .comment-body { 15 | display: inline-block; 16 | padding: $comment-body-padding-y $comment-body-padding-x; 17 | background-color: $comment-body-bg; 18 | border-radius: $comment-body-border-radius; 19 | } 20 | 21 | .comment-time { 22 | display: block; 23 | margin-bottom: $comment-time-margin-bottom; 24 | font-size: $comment-time-font-size; 25 | color: $comment-time-color; 26 | } 27 | 28 | .comment-text { 29 | font-size: $comment-body-font-size; 30 | } 31 | 32 | .comment-text:last-child { 33 | margin-bottom: 0; 34 | } -------------------------------------------------------------------------------- /client/src/styles/dashkit/_custom-forms.scss: -------------------------------------------------------------------------------- 1 | // 2 | // custom-forms.scss 3 | // Extended from Bootstrap 4 | // 5 | 6 | // 7 | // Bootstrap Overrides =================================== 8 | // 9 | 10 | // Switch 11 | 12 | .custom-switch { 13 | min-height: $custom-switch-height; 14 | 15 | .custom-control-label { 16 | 17 | &::before { 18 | top: 0; 19 | height: $custom-switch-height; 20 | border-radius: $custom-switch-height / 2; 21 | } 22 | 23 | &::after { 24 | top: $custom-switch-spacing; 25 | left: $custom-switch-spacing - $custom-control-gutter - $custom-switch-width; 26 | background-color: $custom-switch-indicator-bg; 27 | } 28 | } 29 | 30 | .custom-control-input:checked ~ .custom-control-label { 31 | 32 | &::after { 33 | background-color: $custom-switch-indicator-active-bg; 34 | transform: translateX($custom-switch-width - $custom-switch-spacing * 2 - $custom-switch-indicator-size); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /client/src/styles/dashkit/_dashkit.scss: -------------------------------------------------------------------------------- 1 | // Extended Bootstrap components 2 | 3 | @import 'mixins'; 4 | @import 'alert'; 5 | @import 'badge'; 6 | @import 'breadcrumb'; 7 | @import 'buttons'; 8 | @import 'card'; 9 | @import 'close'; 10 | @import 'code'; 11 | @import 'custom-forms'; 12 | @import 'dropdowns'; 13 | @import 'forms'; 14 | @import 'jumbotron'; 15 | @import 'list-group'; 16 | @import 'modal'; 17 | @import 'nav'; 18 | @import 'navbar'; 19 | @import 'popover'; 20 | @import 'progress'; 21 | @import 'reboot'; 22 | @import 'root'; 23 | @import 'tables'; 24 | @import 'toasts'; 25 | @import 'type'; 26 | @import 'utilities'; 27 | 28 | // Dashkit only components 29 | 30 | @import 'avatar'; 31 | @import 'chart'; 32 | @import 'comment'; 33 | @import 'checklist'; 34 | @import 'header'; 35 | @import 'icon'; 36 | @import 'kanban'; 37 | @import 'main-content'; 38 | @import 'vendors'; 39 | -------------------------------------------------------------------------------- /client/src/styles/dashkit/_dropdowns.scss: -------------------------------------------------------------------------------- 1 | // 2 | // dropdowns.scss 3 | // Extended from Bootstrap 4 | // 5 | 6 | // 7 | // Bootstrap overrides ================================== 8 | // 9 | 10 | // Dropdown arrows 11 | // 12 | // Replace original arrows with Feather icons 13 | 14 | .dropdown-toggle { 15 | 16 | &::after { 17 | width: auto; 18 | height: auto; 19 | border: none !important; 20 | vertical-align: middle; 21 | font-family: 'Feather'; 22 | } 23 | 24 | &::after { 25 | content: '\e92e'; 26 | } 27 | } 28 | 29 | .dropup > .dropdown-toggle { 30 | 31 | &::after { 32 | content: '\e931'; 33 | } 34 | } 35 | 36 | .dropright > .dropdown-toggle { 37 | 38 | &::after { 39 | content: '\e930'; 40 | } 41 | } 42 | 43 | .dropleft > .dropdown-toggle { 44 | 45 | &::before { 46 | content: '\e92f'; 47 | width: auto; 48 | height: auto; 49 | border: none !important; 50 | vertical-align: middle; 51 | font-family: 'Feather'; 52 | } 53 | } 54 | 55 | 56 | // Dropdown toggle 57 | // 58 | // Right align arrows 59 | 60 | .dropdown-item.dropdown-toggle { 61 | display: flex; 62 | justify-content: space-between; 63 | } 64 | 65 | 66 | // Dropdown menu animation 67 | // 68 | // Animate dropdown menu appearance 69 | 70 | .dropdown-menu { 71 | animation: dropdownMenu .15s; 72 | } 73 | 74 | @keyframes dropdownMenu { 75 | from { 76 | opacity: 0; 77 | } 78 | 79 | to { 80 | opacity: 1; 81 | } 82 | } 83 | 84 | 85 | 86 | // 87 | // Dashkit =================================== 88 | // 89 | 90 | 91 | // Dropdown ellipses 92 | // 93 | // Styles the ellipses icon and removes the dropdown arrow 94 | 95 | .dropdown-ellipses { 96 | font-size: $font-size-lg; 97 | color: $gray-400; 98 | } 99 | 100 | .dropdown-ellipses::after { 101 | display: none; 102 | } 103 | 104 | 105 | // Dropdown card 106 | // 107 | // Makes the dropdown menu act like a card 108 | 109 | .dropdown-menu-card { 110 | min-width: $dropdown-card-min-width; 111 | padding-top: 0; 112 | padding-bottom: 0; 113 | background-color: $card-bg; 114 | border-color: $dropdown-card-border-color; 115 | 116 | .card-header { 117 | min-height: $dropdown-card-header-min-height; 118 | } 119 | 120 | .card-body { 121 | max-height: $dropdown-card-body-max-height; 122 | overflow-y: auto; 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /client/src/styles/dashkit/_header.scss: -------------------------------------------------------------------------------- 1 | // 2 | // header.scss 3 | // Dashkit component 4 | // 5 | 6 | // Header 7 | // 8 | // General styles 9 | 10 | .header { 11 | margin-bottom: $header-margin-bottom; 12 | } 13 | 14 | .header-img-top { 15 | width: 100%; height: auto; 16 | } 17 | 18 | .header-body { 19 | padding-top: $header-spacing-y; 20 | padding-bottom: $header-spacing-y; 21 | border-bottom: $header-body-border-width solid $header-body-border-color; 22 | } 23 | 24 | .header.bg-dark .header-body, 25 | .header.bg-hero .header-body { 26 | border-bottom-color: $header-body-border-color-dark; 27 | } 28 | 29 | .header-footer { 30 | padding-top: $header-spacing-y; 31 | padding-bottom: $header-spacing-y; 32 | } 33 | 34 | .header-pretitle { 35 | text-transform: uppercase; 36 | letter-spacing: .08em; 37 | color: $text-muted; 38 | } 39 | 40 | .header-title { 41 | margin-bottom: 0; 42 | } 43 | 44 | .header-subtitle { 45 | margin-top: map-get($spacers, 2); 46 | margin-bottom: 0; 47 | color: $text-muted; 48 | } 49 | 50 | .header-tabs { 51 | margin-bottom: -$header-spacing-y; 52 | border-bottom-width: 0; 53 | 54 | .nav-link { 55 | padding-top: $header-spacing-y; 56 | padding-bottom: $header-spacing-y; 57 | } 58 | } -------------------------------------------------------------------------------- /client/src/styles/dashkit/_icon.scss: -------------------------------------------------------------------------------- 1 | // 2 | // icon.scss 3 | // Dashkit component 4 | // 5 | 6 | // Icon 7 | // 8 | // General styles 9 | 10 | .icon { 11 | display: inline-block; 12 | 13 | // Feather icon 14 | 15 | > .fe { 16 | display: block; 17 | min-width: 1em * $line-height-base; 18 | min-height: 1em * $line-height-base; 19 | text-align: center; 20 | font-size: $font-size-lg; 21 | } 22 | 23 | // Active state 24 | 25 | &.active { 26 | position: relative; 27 | 28 | // Feather icon 29 | 30 | > .fe { 31 | mask-image: url(#{$path-to-img}/masks/icon-status.svg); 32 | mask-size: 100% 100%; 33 | } 34 | 35 | // Indicator 36 | 37 | &::after { 38 | content: ""; 39 | position: absolute; 40 | top: 10%; right: 20%; 41 | width: 20%; height: 20%; 42 | border-radius: 50%; 43 | background-color: $primary; 44 | } 45 | } 46 | } 47 | 48 | // Feather icons 49 | // 50 | // Fixes icon / font vertical alignment issue 51 | 52 | .fe { 53 | line-height: inherit; 54 | } -------------------------------------------------------------------------------- /client/src/styles/dashkit/_jumbotron.scss: -------------------------------------------------------------------------------- 1 | // 2 | // jumbotron.scss 3 | // Extended from Bootstrap 4 | // 5 | 6 | // 7 | // Bootstrap Overrides ===================================== 8 | // 9 | 10 | .jumbotron { 11 | padding: ($jumbotron-padding / 2); 12 | @include media-breakpoint-up(sm) { 13 | padding: $jumbotron-padding; 14 | } 15 | } -------------------------------------------------------------------------------- /client/src/styles/dashkit/_kanban.scss: -------------------------------------------------------------------------------- 1 | // 2 | // kanban.scss 3 | // Dashkit component 4 | // 5 | 6 | // Container 7 | 8 | .container-fluid.kanban-container { 9 | min-height: calc(100vh - 129px); 10 | } 11 | 12 | .container.kanban-container { 13 | min-height: calc(100vh - 129px - 69px); 14 | } 15 | 16 | .kanban-container { 17 | overflow-x: scroll; 18 | -webkit-overflow-scrolling: touch; 19 | } 20 | 21 | .kanban-container > .row { 22 | flex-wrap: nowrap; 23 | } 24 | 25 | .kanban-container > .row > [class*="col"] { 26 | max-width: $kanban-col-width; 27 | } 28 | 29 | 30 | // Category 31 | 32 | .kanban-category { 33 | min-height: 1rem; 34 | } 35 | 36 | 37 | // Item 38 | 39 | .kanban-item { 40 | outline: none; 41 | user-select: none; 42 | } 43 | 44 | .kanban-item.draggable-source--is-dragging { 45 | opacity: .2; 46 | } 47 | 48 | .kanban-item.draggable-mirror { 49 | z-index: $zindex-fixed; 50 | } 51 | 52 | .card-body .kanban-item.draggable-mirror > .card { 53 | transform: rotateZ(-3deg); 54 | } 55 | 56 | 57 | // Card 58 | 59 | .kanban-item > .card[data-toggle="modal"] { 60 | cursor: pointer; 61 | } 62 | 63 | 64 | // Add form 65 | 66 | .kanban-add-form .form-control[data-toggle="flatpickr"] { 67 | width: 12ch; // there is no CSS way to set input's width to auto so hardcoding this value 68 | } 69 | -------------------------------------------------------------------------------- /client/src/styles/dashkit/_list-group.scss: -------------------------------------------------------------------------------- 1 | // 2 | // list-group.scss 3 | // Extended from Bootstrap 4 | // 5 | 6 | // 7 | // Bootstrap Overrides ===================================== 8 | // 9 | 10 | // Contextual variants 11 | // 12 | // Changing the Bootstrap color modifier classes to be full opacity background with yiq calculated font color 13 | 14 | @each $color, $value in $theme-colors { 15 | @include list-group-item-variant($color, $value, color-yiq($value)); 16 | } 17 | 18 | 19 | // List group large 20 | 21 | .list-group-lg .list-group-item { 22 | padding-top: $list-group-item-padding-y-lg; 23 | padding-bottom: $list-group-item-padding-y-lg; 24 | } 25 | 26 | 27 | // List group flush 28 | 29 | .list-group-flush > .list-group-item { 30 | padding-left: 0; 31 | padding-right: 0; 32 | } 33 | 34 | 35 | // 36 | // Dashkit =================================== 37 | // 38 | 39 | 40 | // Activity 41 | 42 | .list-group-activity .list-group-item { 43 | border: 0; 44 | } 45 | 46 | .list-group-activity .list-group-item:not(:last-child)::before { 47 | content: ''; 48 | position: absolute; 49 | top: $list-group-item-padding-y; 50 | left: $avatar-size-sm / 2; 51 | height: 100%; 52 | border-left: $border-width solid $border-color; 53 | } 54 | -------------------------------------------------------------------------------- /client/src/styles/dashkit/_main-content.scss: -------------------------------------------------------------------------------- 1 | // 2 | // main-content.scss 3 | // Dashkit component 4 | // 5 | 6 | // Main content 7 | // 8 | // General styles 9 | 10 | .navbar-vertical:not([style*="display: none"]) ~ .main-content, 11 | .navbar-vertical-sm:not([style*="display: none"]) ~ .main-content { 12 | 13 | .container, 14 | .container-fluid { 15 | 16 | @include media-breakpoint-up(md) { 17 | padding-left: ($main-content-padding-x + $grid-gutter-width / 2) !important; 18 | padding-right: ($main-content-padding-x + $grid-gutter-width / 2) !important; 19 | } 20 | } 21 | } 22 | 23 | 24 | // Main content offset 25 | // 26 | // Offsets the main content depending on the sidebar positioning 27 | 28 | .navbar-vertical.navbar-expand { 29 | 30 | @each $breakpoint, $value in $grid-breakpoints { 31 | &-#{$breakpoint} { 32 | @include media-breakpoint-up(#{$breakpoint}) { 33 | 34 | // Left 35 | 36 | &.fixed-left:not([style*="display: none"]) ~ .main-content { 37 | margin-left: $navbar-vertical-width; 38 | } 39 | 40 | // Right 41 | 42 | &.fixed-right:not([style*="display: none"]) ~ .main-content { 43 | margin-right: $navbar-vertical-width; 44 | } 45 | } 46 | } 47 | } 48 | } 49 | 50 | .navbar-vertical-sm.navbar-expand { 51 | 52 | @each $breakpoint, $value in $grid-breakpoints { 53 | &-#{$breakpoint} { 54 | @include media-breakpoint-up(#{$breakpoint}) { 55 | 56 | // Left 57 | 58 | &.fixed-left:not([style*="display: none"]) ~ .main-content { 59 | margin-left: $navbar-vertical-width-sm; 60 | } 61 | 62 | // Right 63 | 64 | &.fixed-right:not([style*="display: none"]) ~ .main-content { 65 | margin-right: $navbar-vertical-width-sm; 66 | } 67 | } 68 | } 69 | } 70 | } -------------------------------------------------------------------------------- /client/src/styles/dashkit/_mixins.scss: -------------------------------------------------------------------------------- 1 | // Mixins 2 | // 3 | // 4 | 5 | // Utilities 6 | @import "mixins/breakpoints"; 7 | @import "mixins/badge"; 8 | 9 | // Components 10 | // ... 11 | 12 | // Skins 13 | // ... 14 | 15 | // Layout 16 | // ... -------------------------------------------------------------------------------- /client/src/styles/dashkit/_modal.scss: -------------------------------------------------------------------------------- 1 | // 2 | // modal.scss 3 | // Extended from Bootstrap 4 | // 5 | 6 | // 7 | // Bootstrap Overrides ======================= 8 | // 9 | 10 | .modal-dialog { 11 | 12 | // When fading in the modal, animate it to slide down 13 | .modal.fade & { 14 | transform: translate(0, -150px); 15 | } 16 | 17 | .modal.show & { 18 | transform: translate(0, 0); 19 | } 20 | } 21 | 22 | .modal-header .close { 23 | margin: -1.5rem -1.5rem -1.5rem auto; 24 | } 25 | 26 | 27 | // 28 | // Dashkit =================================== 29 | // 30 | 31 | // Modal dialog vertical 32 | // 33 | // Creates a vertically aligned version of the modal dialog 34 | 35 | .modal-dialog-vertical { 36 | height: 100%; 37 | max-width: $modal-dialog-vertical-width; 38 | margin: 0; 39 | 40 | .modal-content { 41 | height: inherit; 42 | border-width: 0 $modal-content-border-width 0 0; 43 | border-radius: 0; 44 | } 45 | 46 | .modal-header { 47 | border-radius: inherit; 48 | } 49 | 50 | .modal-body { 51 | height: inherit; 52 | overflow-y: auto; 53 | } 54 | } 55 | 56 | .modal.fade .modal-dialog-vertical { 57 | transform: translateX(-100%); 58 | } 59 | 60 | .modal.show .modal-dialog-vertical { 61 | transform: translateX(0); 62 | } 63 | 64 | 65 | // Positioning 66 | 67 | .modal.fixed-right { 68 | padding-right: 0 !important; 69 | } 70 | 71 | .modal.fixed-right .modal-dialog-vertical { 72 | margin-left: auto; 73 | } 74 | 75 | .modal.fixed-right.fade .modal-dialog-vertical { 76 | transform: translateX(100%); 77 | } 78 | 79 | .modal.fixed-right.show .modal-dialog-vertical { 80 | transform: translateX(0); 81 | } 82 | 83 | 84 | // Modal card 85 | 86 | .modal-card { 87 | margin-bottom: 0; 88 | 89 | .card-body { 90 | max-height: $modal-card-body-max-height; 91 | overflow-y: auto; 92 | } 93 | } 94 | 95 | 96 | // Modal tabs 97 | 98 | .modal-header-tabs { 99 | margin-top: -$modal-header-padding-y; 100 | margin-bottom: calc(-#{$modal-header-padding-y} - #{$border-width}); 101 | } 102 | -------------------------------------------------------------------------------- /client/src/styles/dashkit/_nav.scss: -------------------------------------------------------------------------------- 1 | // 2 | // nav.scss 3 | // Extended from Bootstrap 4 | // 5 | 6 | // 7 | // Bootstrap Overrides ===================================== 8 | // 9 | 10 | // Changing nav tabs to be bottom highlight style 11 | 12 | .nav-tabs { 13 | 14 | .nav-item { 15 | margin-left: $nav-tabs-link-margin-x; 16 | margin-right: $nav-tabs-link-margin-x; 17 | } 18 | 19 | .nav-link { 20 | padding: $nav-tabs-link-padding-y 0; 21 | border-bottom: $nav-tabs-link-active-border-width solid transparent; 22 | border-left-width: 0; 23 | border-right-width: 0; 24 | border-top-width: 0; 25 | 26 | &:not(.active) { 27 | color: $gray-600; 28 | 29 | &:hover { 30 | color: $gray-700; 31 | } 32 | } 33 | 34 | } 35 | 36 | .nav-item:first-child { 37 | margin-left: 0; 38 | } 39 | 40 | .nav-item:last-child { 41 | margin-right: 0; 42 | } 43 | 44 | // Removes the primary color underline from dropdowns in .nav-tabs 45 | .nav-item.show .nav-link { 46 | border-color: transparent; 47 | } 48 | 49 | } 50 | 51 | 52 | // 53 | // Dashkit ===================================== 54 | // 55 | 56 | // Nav overflow 57 | // 58 | // Allow links to overflow and make horizontally scrollable 59 | 60 | .nav-overflow { 61 | display: flex; 62 | flex-wrap: nowrap; 63 | overflow-x: auto; 64 | padding-bottom: 1px; // to prevent active links border bottom from hiding 65 | 66 | // Hide scrollbar 67 | 68 | &::-webkit-scrollbar { 69 | display: none; 70 | } 71 | } 72 | 73 | 74 | // Creates a small version of the .nav-tabs 75 | 76 | .nav-tabs-sm { 77 | font-size: $nav-tabs-sm-font-size; 78 | 79 | .nav-item { 80 | margin-left: $nav-tabs-sm-link-margin-x; 81 | margin-right: $nav-tabs-sm-link-margin-x; 82 | } 83 | 84 | .nav-link { 85 | // Calculates the exact padding necessary to vertically fill the .card-header 86 | padding-top: (($font-size-base / $nav-tabs-sm-font-size) * $nav-tabs-link-padding-y); 87 | padding-bottom: (($font-size-base / $nav-tabs-sm-font-size) * $nav-tabs-link-padding-y); 88 | } 89 | } 90 | 91 | // Creates a small version of the .nab 92 | 93 | .nav-sm { 94 | 95 | .nav-link { 96 | font-size: $font-size-sm; 97 | } 98 | } 99 | 100 | 101 | // Nav + button group 102 | // 103 | // Change the look of .btn-white when .active 104 | 105 | .nav.btn-group { 106 | 107 | .btn-white.active { 108 | background-color: $primary; 109 | border-color: $primary; 110 | color: $white; 111 | } 112 | } -------------------------------------------------------------------------------- /client/src/styles/dashkit/_popover.scss: -------------------------------------------------------------------------------- 1 | // 2 | // popover.scss 3 | // Extended from Bootstrap 4 | // 5 | 6 | // 7 | // Bootstrap Overrides ===================================== 8 | // 9 | 10 | .popover { 11 | padding: $popover-padding-y $popover-padding-x; 12 | 13 | &:hover { 14 | visibility: visible !important; 15 | } 16 | } 17 | 18 | .popover-header { 19 | margin-bottom: $popover-header-margin-bottom; 20 | border-bottom: 0; 21 | } 22 | 23 | .popover-body-label { 24 | margin-left: .25rem; 25 | } 26 | 27 | .popover-body-value { 28 | margin-left: .25rem; 29 | } 30 | 31 | .popover-body-indicator { 32 | display: inline-block; 33 | width: .5rem; 34 | height: .5rem; 35 | border-radius: 50%; 36 | } 37 | 38 | 39 | // Large 40 | 41 | .popover-lg { 42 | max-width: $popover-lg-max-width; 43 | } 44 | 45 | 46 | // Dark 47 | 48 | .popover-dark { 49 | background-color: $popover-dark-bg; 50 | border-color: $popover-dark-border-color; 51 | } 52 | 53 | .popover-dark .popover-header { 54 | font-weight: $font-weight-normal; 55 | background-color: $popover-dark-header-bg; 56 | color: $popover-dark-header-color; 57 | } 58 | 59 | .popover-dark.bs-popover-top .arrow { 60 | 61 | &::before { 62 | border-top-color: $popover-dark-border-color; 63 | } 64 | 65 | &::after { 66 | border-top-color: $popover-dark-bg; 67 | } 68 | } 69 | 70 | .popover-dark.bs-popover-right .arrow { 71 | 72 | &::before { 73 | border-right-color: $popover-dark-border-color; 74 | } 75 | 76 | &::after { 77 | border-right-color: $popover-dark-bg; 78 | } 79 | } 80 | 81 | .popover-dark.bs-popover-bottom .arrow { 82 | 83 | &::before { 84 | border-bottom-color: $popover-dark-border-color; 85 | } 86 | 87 | &::after { 88 | border-bottom-color: $popover-dark-bg; 89 | } 90 | } 91 | 92 | .popover-dark.bs-popover-left .arrow { 93 | 94 | &::before { 95 | border-left-color: $popover-dark-border-color; 96 | } 97 | 98 | &::after { 99 | border-left-color: $popover-dark-bg; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /client/src/styles/dashkit/_progress.scss: -------------------------------------------------------------------------------- 1 | // 2 | // progress.scss 3 | // Extended from Bootstrap 4 | // 5 | 6 | // 7 | // Bootstrap Overrides ===================================== 8 | // 9 | 10 | // Rounds the progress bar, even for "multiple bar" progress bars 11 | .progress-bar:first-child { 12 | border-top-left-radius: $progress-border-radius; 13 | border-bottom-left-radius: $progress-border-radius; 14 | } 15 | .progress-bar:last-child { 16 | border-top-right-radius: $progress-border-radius; 17 | border-bottom-right-radius: $progress-border-radius; 18 | } 19 | 20 | 21 | // 22 | // Dashkit =================================== 23 | // 24 | 25 | .progress-sm { 26 | height: $progress-height-sm; 27 | } -------------------------------------------------------------------------------- /client/src/styles/dashkit/_reboot.scss: -------------------------------------------------------------------------------- 1 | // 2 | // reboot.scss 3 | // Extended from Bootstrap 4 | // 5 | 6 | // 7 | // Remove the cancel buttons in Chrome and Safari on macOS. 8 | // 9 | 10 | [type="search"]::-webkit-search-cancel-button { 11 | -webkit-appearance: none; 12 | } -------------------------------------------------------------------------------- /client/src/styles/dashkit/_root.scss: -------------------------------------------------------------------------------- 1 | // 2 | // root.scss 3 | // Extended from Bootstrap 4 | // 5 | 6 | html { 7 | height: 100%; 8 | } 9 | 10 | body { 11 | min-height: 100%; 12 | } -------------------------------------------------------------------------------- /client/src/styles/dashkit/_tables.scss: -------------------------------------------------------------------------------- 1 | // 2 | // tables.scss 3 | // Extended from Bootstrap 4 | // 5 | 6 | // 7 | // Bootstrap Overrides ===================================== 8 | // 9 | 10 | .table { 11 | 12 | thead th { 13 | background-color: $table-head-bg; 14 | text-transform: uppercase; 15 | font-size: $font-size-sm; 16 | font-weight: $font-weight-bold; 17 | letter-spacing: .08em; 18 | color: $table-head-color; 19 | border-bottom-width: $table-border-width; 20 | } 21 | 22 | thead th, tbody th, tbody td { 23 | vertical-align: middle; 24 | } 25 | } 26 | 27 | .table-sm { 28 | font-size: $font-size-sm; 29 | 30 | thead th { 31 | font-size: $font-size-xs; 32 | } 33 | } 34 | 35 | 36 | // 37 | // Dashkit ===================================== 38 | // 39 | 40 | // No wrap 41 | // 42 | // Prevents table content from wrapping to the next line 43 | 44 | .table-nowrap { 45 | 46 | th, td { 47 | white-space: nowrap; 48 | } 49 | } 50 | 51 | 52 | // Sort 53 | // 54 | // Adds sorting icons 55 | 56 | .table [data-sort] { 57 | white-space: nowrap; 58 | 59 | &::after { 60 | content: str-replace(url("data:image/svg+xml;utf8,"), "#", "%23"); 61 | margin-left: .25rem; 62 | } 63 | } 64 | 65 | 66 | // Table checkbox 67 | 68 | .table-checkbox { 69 | min-height: 0; 70 | } 71 | 72 | .table-checkbox .custom-control-label::before, 73 | .table-checkbox .custom-control-label::after { 74 | top: 50%; 75 | transform: translateY(-50%); 76 | } 77 | -------------------------------------------------------------------------------- /client/src/styles/dashkit/_toasts.scss: -------------------------------------------------------------------------------- 1 | // 2 | // toasts.scss 3 | // Extended from Bootstrap 4 | // 5 | -------------------------------------------------------------------------------- /client/src/styles/dashkit/_type.scss: -------------------------------------------------------------------------------- 1 | // 2 | // type.scss 3 | // Extended from Bootstrap 4 | // 5 | 6 | // 7 | // Bootstrap Overrides ===================================== 8 | // 9 | 10 | h1, .h1 { 11 | margin-bottom: $headings-margin-bottom; 12 | font-size: 1.5rem; 13 | 14 | @include media-breakpoint-up(md) { 15 | font-size: $h1-font-size; 16 | } 17 | } 18 | 19 | h2, .h2 { 20 | margin-bottom: $headings-margin-bottom; 21 | } 22 | 23 | h3, .h3 { 24 | margin-bottom: ($headings-margin-bottom * .75); 25 | } 26 | 27 | h4, .h4 { 28 | margin-bottom: ($headings-margin-bottom * .5); 29 | } 30 | 31 | h5, .h5 { 32 | margin-bottom: ($headings-margin-bottom * .5); 33 | } 34 | 35 | h6, .h6 { 36 | margin-bottom: ($headings-margin-bottom * .5); 37 | } 38 | 39 | 40 | // Links 41 | 42 | h1, .h1, h2, .h2, h3, .h3, h4, .h4, h5, .h5, h6, .h6 { 43 | 44 | > a { 45 | color: inherit; 46 | } 47 | } 48 | 49 | // Type display classes 50 | 51 | .display-1, 52 | .display-2, 53 | .display-3, 54 | .display-4 { 55 | letter-spacing: $display-letter-spacing; 56 | } 57 | 58 | // Headings 59 | 60 | h1, .h1, h2, .h2, h3, .h3, h4, .h4, h5, .h5, h6, .h6 { 61 | letter-spacing: $headings-letter-spacing; 62 | } 63 | 64 | h6.text-uppercase { 65 | letter-spacing: .08em; 66 | } 67 | 68 | // Bold, strong 69 | 70 | b, strong { 71 | font-weight: $font-weight-bold; 72 | } 73 | 74 | 75 | // Links, buttons 76 | // 77 | // Removes focus outline 78 | 79 | a, button { 80 | 81 | &:focus { 82 | outline: none !important; 83 | } 84 | } 85 | 86 | 87 | // 88 | // Dashkit ===================================== 89 | // 90 | 91 | // Include Cerebri Sans 92 | 93 | @font-face { 94 | font-family: 'Cerebri Sans'; 95 | src: url('#{$path-to-fonts}/cerebrisans/cerebrisans-regular.eot'); 96 | src: url('#{$path-to-fonts}/cerebrisans/cerebrisans-regular.eot?#iefix') format('embedded-opentype'), url('#{$path-to-fonts}/cerebrisans/cerebrisans-regular.woff') format('woff'), url('#{$path-to-fonts}/cerebrisans/cerebrisans-regular.ttf') format('truetype'); 97 | font-weight: 400; 98 | font-style: normal; 99 | } 100 | 101 | @font-face { 102 | font-family: 'Cerebri Sans'; 103 | src: url('#{$path-to-fonts}/cerebrisans/cerebrisans-medium.eot'); 104 | src: url('#{$path-to-fonts}/cerebrisans/cerebrisans-medium.eot?#iefix') format('embedded-opentype'), url('#{$path-to-fonts}/cerebrisans/cerebrisans-medium.woff') format('woff'), url('#{$path-to-fonts}/cerebrisans/cerebrisans-medium.ttf') format('truetype'); 105 | font-weight: 500; 106 | font-style: normal; 107 | } 108 | 109 | @font-face { 110 | font-family: 'Cerebri Sans'; 111 | src: url('#{$path-to-fonts}/cerebrisans/cerebrisans-semibold.eot'); 112 | src: url('#{$path-to-fonts}/cerebrisans/cerebrisans-semibold.eot?#iefix') format('embedded-opentype'), url('#{$path-to-fonts}/cerebrisans/cerebrisans-semibold.woff') format('woff'), url('#{$path-to-fonts}/cerebrisans/cerebrisans-semibold.ttf') format('truetype'); 113 | font-weight: 600; 114 | font-style: normal; 115 | } 116 | -------------------------------------------------------------------------------- /client/src/styles/dashkit/_utilities.scss: -------------------------------------------------------------------------------- 1 | @import 'utilities/background'; 2 | @import 'utilities/borders'; 3 | @import 'utilities/lift'; 4 | @import 'utilities/sizing'; 5 | @import 'utilities/type'; -------------------------------------------------------------------------------- /client/src/styles/dashkit/_vendors.scss: -------------------------------------------------------------------------------- 1 | @import 'vendors/dropzone'; 2 | @import 'vendors/flatpickr'; 3 | @import 'vendors/quill'; 4 | @import 'vendors/list'; 5 | @import 'vendors/select2'; 6 | -------------------------------------------------------------------------------- /client/src/styles/dashkit/dark/_overrides-dark.scss: -------------------------------------------------------------------------------- 1 | // 2 | // overrides.scss 3 | // Dark mode overrides 4 | // 5 | 6 | // 7 | // Table of contents 8 | // 9 | // 1. Buttons 10 | // 2. Forms 11 | // 3. Input groups 12 | // 4. Quill 13 | // 5. Select2 14 | // 15 | 16 | 17 | // Buttons 18 | 19 | .btn-white, .btn-light { 20 | @include button-variant($gray-800-dark, $gray-600-dark); 21 | 22 | &:not(:disabled):not(.disabled):hover, 23 | &:not(:disabled):not(.disabled):focus, 24 | &:not(:disabled):not(.disabled):active, 25 | &:not(:disabled):not(.disabled).active, 26 | &:not(:disabled):not(.disabled):active:focus, 27 | &:not(:disabled):not(.disabled).active:focus, 28 | .show > &.dropdown-toggle { 29 | background-color: $black-dark; 30 | border-color: $gray-700-dark; 31 | color: $white; 32 | } 33 | } 34 | 35 | 36 | // Forms 37 | 38 | .form-control { 39 | border-color: $input-bg; 40 | } 41 | 42 | 43 | // Input groups 44 | 45 | .input-group .input-group-text { 46 | border-color: $input-bg; 47 | } 48 | 49 | 50 | // Quill 51 | 52 | .ql-toolbar { 53 | border-color: $input-bg; 54 | } 55 | 56 | .ql-editor { 57 | border-left-color: $input-bg; 58 | border-right-color: $input-bg; 59 | border-bottom-color: $input-bg; 60 | } 61 | -------------------------------------------------------------------------------- /client/src/styles/dashkit/mixins/_badge.scss: -------------------------------------------------------------------------------- 1 | // Badge Mixins 2 | // 3 | // This is a custom mixin for badge-#{color}-soft variant of Bootstrap's .badge class 4 | 5 | @mixin badge-variant-soft($bg, $color) { 6 | color: $color; 7 | background-color: $bg; 8 | 9 | &[href] { 10 | @include hover-focus { 11 | color: $color; 12 | text-decoration: none; 13 | background-color: darken($bg, 5%); 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /client/src/styles/dashkit/mixins/_breakpoints.scss: -------------------------------------------------------------------------------- 1 | // 2 | // breakpoint.scss 3 | // Extended from Bootstrap 4 | // 5 | 6 | @function breakpoint-prev($name, $breakpoints: $grid-breakpoints, $breakpoint-names: map-keys($breakpoints)) { 7 | $n: index($breakpoint-names, $name); 8 | @return if($n != null and $n != 1, nth($breakpoint-names, $n - 1), null); 9 | } -------------------------------------------------------------------------------- /client/src/styles/dashkit/utilities/_background.scss: -------------------------------------------------------------------------------- 1 | // 2 | // background.scss 3 | // 4 | 5 | 6 | // Fixed at the bottom 7 | 8 | .bg-fixed-bottom { 9 | background-repeat: no-repeat; 10 | background-position: right bottom; 11 | background-size: 100% auto; 12 | background-attachment: fixed; 13 | } 14 | 15 | // Calculate the width of the main container because 16 | // the background-attachment property will use 100vw instead 17 | 18 | .navbar-vertical ~ .main-content.bg-fixed-bottom { 19 | background-size: 100%; 20 | 21 | @include media-breakpoint-up(md) { 22 | background-size: calc(100% - #{$navbar-vertical-width}); 23 | } 24 | } 25 | 26 | 27 | // Cover 28 | 29 | .bg-cover { 30 | background-repeat: no-repeat; 31 | background-position: center center; 32 | background-size: cover; 33 | } 34 | 35 | 36 | // Auth 37 | 38 | .bg-auth { 39 | background-color: $auth-bg; 40 | } 41 | 42 | 43 | // Ellipses 44 | 45 | @each $color, $value in $theme-colors { 46 | 47 | .bg-ellipses.bg-#{$color} { 48 | background-color: transparent !important; 49 | background-repeat: no-repeat; 50 | background-image: radial-gradient(#{$value}, #{$value} 70%, transparent 70.1%); 51 | background-size: 200% 150%; 52 | background-position: center bottom; 53 | } 54 | } 55 | 56 | 57 | // Hero 58 | 59 | .bg-hero { 60 | background-image: linear-gradient(to bottom, fade-out($black, .15), fade-out($black, .15)), url(../#{$path-to-img}/covers/header-cover.jpg); 61 | background-repeat: no-repeat, no-repeat; 62 | background-position: center center, center center; 63 | background-size: cover, cover; 64 | } 65 | 66 | 67 | // Colors 68 | 69 | .bg-lighter { 70 | background-color: $lighter !important; 71 | } 72 | 73 | 74 | // Soft colors 75 | 76 | @each $color, $value in $theme-colors { 77 | .bg-#{$color}-soft { 78 | background-color: theme-color-level($color, $bg-soft-level) !important; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /client/src/styles/dashkit/utilities/_borders.scss: -------------------------------------------------------------------------------- 1 | // 2 | // borders.scss 3 | // 4 | 5 | 6 | // Sizing 7 | 8 | $border-sizing: () !default; 9 | $border-sizing: map-merge(( 10 | "2": 2, 11 | "3": 3, 12 | "4": 4, 13 | "5": 5 14 | ), $border-sizing); 15 | 16 | @each $size, $value in $border-sizing { 17 | 18 | .border-#{$size} { 19 | border-width: $border-width * $value !important; 20 | } 21 | 22 | .border-top-#{$size} { 23 | border-top-width: $border-width * $value !important; 24 | } 25 | 26 | .border-right-#{$size} { 27 | border-right-width: $border-width * $value !important; 28 | } 29 | 30 | .border-bottom-#{$size} { 31 | border-bottom-width: $border-width * $value !important; 32 | } 33 | 34 | .border-left-#{$size} { 35 | border-left-width: $border-width * $value !important; 36 | } 37 | } 38 | 39 | 40 | // Contextual classes 41 | 42 | .border-body { 43 | border-color: $body-bg !important; 44 | } 45 | 46 | .border-card { 47 | border-color: $card-bg !important; 48 | } -------------------------------------------------------------------------------- /client/src/styles/dashkit/utilities/_lift.scss: -------------------------------------------------------------------------------- 1 | // 2 | // lift.scss 3 | // Theme utility 4 | // 5 | 6 | .lift { 7 | transition: box-shadow .25s ease, transform .25s ease; 8 | } 9 | 10 | .lift:hover, 11 | .lift:focus { 12 | box-shadow: $box-shadow-lift !important; 13 | transform: translate3d(0, -3px, 0); 14 | } 15 | 16 | .lift-lg:hover, 17 | .lift-lg:focus { 18 | box-shadow: $box-shadow-lift-lg !important; 19 | transform: translate3d(0, -5px, 0); 20 | } -------------------------------------------------------------------------------- /client/src/styles/dashkit/utilities/_sizing.scss: -------------------------------------------------------------------------------- 1 | // 2 | // sizing.scss 3 | // 4 | 5 | .vw-100 { 6 | width: 100vw !important; 7 | } 8 | .vh-100 { 9 | height: 100vh !important; 10 | } -------------------------------------------------------------------------------- /client/src/styles/dashkit/utilities/_type.scss: -------------------------------------------------------------------------------- 1 | // 2 | // type.scss 3 | // Extended from Bootstrap 4 | // 5 | 6 | // Font size 7 | 8 | .font-size-base { 9 | font-size: $font-size-base !important; 10 | } 11 | 12 | .font-size-sm { 13 | font-size: $font-size-sm !important; 14 | } 15 | 16 | .font-size-lg { 17 | font-size: $font-size-lg !important; 18 | } 19 | 20 | 21 | // Decoration 22 | 23 | .text-decoration-underline { 24 | text-decoration: underline !important; 25 | } 26 | 27 | 28 | // Gray colors 29 | 30 | @each $color, $value in $grays { 31 | .text-gray-#{$color} { 32 | color: $value !important; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /client/src/styles/dashkit/vendors/_dropzone.scss: -------------------------------------------------------------------------------- 1 | // 2 | // dropzone.scss 3 | // Dropzone plugin overrides 4 | // 5 | 6 | .dropzone { 7 | position: relative; 8 | display: flex; 9 | flex-direction: column; 10 | } 11 | 12 | .dz-message { 13 | padding: 5rem 1rem; 14 | background-color: $input-bg; 15 | border: $input-border-width dashed $input-border-color; 16 | border-radius: $border-radius; 17 | text-align: center; 18 | color: $text-muted; 19 | transition: $transition-base; 20 | order: -1; 21 | cursor: pointer; 22 | z-index: 999; 23 | 24 | &:hover { 25 | border-color: $text-muted; 26 | color: $body-color; 27 | } 28 | } 29 | 30 | .dz-drag-hover .dz-message { 31 | border-color: $primary; 32 | color: $primary; 33 | } 34 | 35 | .dropzone-multiple .dz-message { 36 | padding-top: 2rem; 37 | padding-bottom: 2rem; 38 | } 39 | 40 | .dropzone-single.dz-max-files-reached .dz-message { 41 | background-color: fade-out($black, .1); 42 | color: white; 43 | opacity: 0; 44 | 45 | &:hover { 46 | opacity: 1; 47 | } 48 | } 49 | 50 | .dz-preview-single { 51 | position: absolute; 52 | top: 0; right: 0; bottom: 0; left: 0; 53 | border-radius: $border-radius; 54 | } 55 | 56 | .dz-preview-cover { 57 | position: absolute; 58 | top: 0; right: 0; bottom: 0; left: 0; 59 | border-radius: $border-radius; 60 | } 61 | 62 | .dz-preview-img { 63 | object-fit: cover; 64 | width: 100%; height: 100%; 65 | border-radius: $border-radius; 66 | } 67 | 68 | .dz-preview-multiple .list-group-item:last-child { 69 | padding-bottom: 0; 70 | border-bottom: 0; 71 | } 72 | 73 | [data-dz-size] strong { 74 | font-weight: $font-weight-normal; 75 | } -------------------------------------------------------------------------------- /client/src/styles/dashkit/vendors/_flatpickr.scss: -------------------------------------------------------------------------------- 1 | // 2 | // flatpickr.scss 3 | // Flatpickr plugin overrides 4 | // 5 | 6 | .flatpickr-calendar { 7 | background-color: $input-bg; 8 | border: $input-border-width solid $input-border-color; 9 | color: $input-color; 10 | box-shadow: none; 11 | 12 | * { 13 | color: inherit !important; 14 | fill: currentColor !important; 15 | } 16 | 17 | &.arrowTop:before { 18 | border-bottom-color: $input-border-color; 19 | } 20 | 21 | &.arrowTop:after { 22 | border-bottom-color: $input-bg; 23 | } 24 | 25 | .flatpickr-months { 26 | padding-top: .625rem; 27 | padding-bottom: .625rem; 28 | } 29 | 30 | .flatpickr-prev-month, 31 | .flatpickr-next-month { 32 | top: .625rem; 33 | } 34 | 35 | .flatpickr-current-month { 36 | font-size: 115%; 37 | } 38 | 39 | .flatpickr-day { 40 | border-radius: $border-radius; 41 | 42 | &:hover { 43 | background-color: $light; 44 | border-color: $input-border-color; 45 | } 46 | } 47 | 48 | .flatpickr-day.prevMonthDay { 49 | color: $text-muted !important; 50 | } 51 | 52 | .flatpickr-day.today { 53 | border-color: $border-color; 54 | } 55 | 56 | .flatpickr-day.selected { 57 | background-color: $primary; 58 | border-color: $primary; 59 | color: $white !important; 60 | } 61 | 62 | .flatpickr-day.inRange { 63 | background-color: $light; 64 | border: none; 65 | border-radius: 0; 66 | box-shadow: -5px 0 0 $light, 5px 0 0 $light; 67 | } 68 | 69 | } -------------------------------------------------------------------------------- /client/src/styles/dashkit/vendors/_list.scss: -------------------------------------------------------------------------------- 1 | // 2 | // list.scss 3 | // 4 | 5 | [data-toggle="lists"] .pagination > li { 6 | @extend .page-item; 7 | } 8 | 9 | [data-toggle="lists"] .pagination .page { 10 | @extend .page-link; 11 | } 12 | -------------------------------------------------------------------------------- /client/src/styles/dashkit/vendors/_select2.scss: -------------------------------------------------------------------------------- 1 | // 2 | // select2.scss 3 | // Select2 plugin overrides 4 | // 5 | 6 | [class*="select2"] { 7 | display: block; 8 | } 9 | 10 | .select2 { 11 | width: 100% !important; 12 | } 13 | 14 | .select2-hidden-accessible { 15 | display: none; 16 | } 17 | 18 | .select2-selection[aria-expanded="true"] { 19 | border-bottom-left-radius: 0; 20 | border-bottom-right-radius: 0; 21 | } 22 | 23 | .select2-container { 24 | display: block; 25 | } 26 | 27 | .select2-dropdown { 28 | margin-top: -$input-border-width; 29 | border-top-left-radius: 0; 30 | border-top-right-radius: 0; 31 | } 32 | 33 | .select2-search--dropdown { 34 | padding: $dropdown-item-padding-y $input-padding-x; 35 | } 36 | 37 | .select2-search--dropdown .select2-search__field { 38 | width: 100%; 39 | height: $input-height-sm; 40 | padding: $input-padding-y-sm $input-padding-x-sm; 41 | background-color: $input-bg; 42 | border: $input-border-width solid $input-border-color; 43 | border-radius: $input-border-radius-sm; 44 | line-height: $input-line-height-sm; 45 | font-size: $input-font-size-sm; 46 | color: $input-color; 47 | transition: $input-transition; 48 | 49 | &:focus { 50 | border-color: $input-focus-border-color; 51 | box-shadow: $input-focus-box-shadow; 52 | outline: none; 53 | } 54 | } 55 | 56 | .select2-results__options { 57 | padding-left: 0; 58 | margin-bottom: 0; 59 | } 60 | 61 | .select2-results__option { 62 | padding: $dropdown-item-padding-y $input-padding-x; 63 | color: $dropdown-link-color; 64 | 65 | &:not(.select2-results__message) { 66 | cursor: pointer; 67 | 68 | @include hover-focus { 69 | color: $dropdown-link-hover-color; 70 | } 71 | } 72 | } 73 | 74 | .select2-results__option[aria-selected="true"], 75 | .select2-results__option--highlighted { 76 | color: $dropdown-link-active-color; 77 | } 78 | 79 | .select2-selection--multiple { 80 | height: auto; 81 | } 82 | 83 | .select2-selection__rendered { 84 | display: flex; 85 | flex-wrap: wrap; 86 | padding-left: 0; 87 | margin: 0 -.25rem -.25rem 0; 88 | } 89 | 90 | .select2-selection__choice { 91 | display: inline-flex; 92 | align-items: center; 93 | padding-left: .375rem; 94 | padding-right: .375rem; 95 | margin: 0 .25rem .25rem 0; 96 | font-size: $font-size-sm; 97 | background-color: $light; 98 | border-radius: $border-radius-xs; 99 | } 100 | 101 | .select2-selection__choice__remove { 102 | order: 2; 103 | margin-left: .5rem; 104 | color: $text-muted; 105 | cursor: pointer; 106 | 107 | @include hover { 108 | color: $body-color; 109 | } 110 | } 111 | 112 | .select2-search--inline .select2-search__field { 113 | height: calc(1em * #{$input-line-height}); 114 | padding-bottom: .25rem; 115 | background-color: transparent; 116 | border: 0; 117 | box-shadow: none; 118 | outline: none; 119 | color: $input-color; 120 | 121 | &::placeholder { 122 | color: $input-placeholder-color; 123 | } 124 | } 125 | 126 | .select2-selection__placeholder { 127 | color: $input-placeholder-color; 128 | } 129 | -------------------------------------------------------------------------------- /client/src/styles/index.scss: -------------------------------------------------------------------------------- 1 | // Icon font 2 | @import "../fonts/feather/feather"; 3 | 4 | // Bootstrap functions 5 | @import "~bootstrap/scss/functions.scss"; 6 | 7 | // Custom variables 8 | @import "custom-variables"; 9 | 10 | // Dark mode variables 11 | @import "dashkit/dark/variables-dark"; 12 | 13 | // Dashkit variables 14 | @import "dashkit/variables"; 15 | 16 | // Bootstrap core 17 | @import "~bootstrap/scss/bootstrap.scss"; 18 | 19 | // Dashkit core 20 | @import "dashkit/dashkit"; 21 | 22 | // Dark mode overrides 23 | @import "dashkit/dark/overrides-dark"; 24 | 25 | // Custom core 26 | @import "custom"; 27 | -------------------------------------------------------------------------------- /client/src/styles/tools/shadows.scss: -------------------------------------------------------------------------------- 1 | //shadows 2 | 3 | //example 4 | // @include box-shadow(3); 5 | 6 | @mixin box-shadow($level) { 7 | @if $level == 1 { 8 | box-shadow: 1px 1px 8px rgba(0, 7, 43, 0.04); 9 | } @else if $level == 2 { 10 | box-shadow: 4px 8px 24px rgba(0, 7, 43, 0.04); 11 | } @else if $level == 3 { 12 | box-shadow: 8px 24px 48px rgba(0, 7, 43, 0.08); 13 | } @else if $level == 4 { 14 | box-shadow: 40px 40px 96px rgba(0, 7, 43, 0.08); 15 | } 16 | } 17 | 18 | //Modal Overlay 19 | @mixin modal-overlay { 20 | background: rgba(0, 7, 43, 0.4); 21 | } 22 | 23 | //Active Inputs Shadow 24 | @mixin active-input-shadow { 25 | box-shadow: 1px 1px 4px rgba(0, 85, 255, 0.08); 26 | } 27 | 28 | //Avatar Inner Shadow 29 | @mixin avatar-inner-shadow { 30 | box-shadow: inset 0px 4px 4px rgba(0, 7, 43, 0.25); 31 | } 32 | -------------------------------------------------------------------------------- /client/src/styles/tools/transitions.scss: -------------------------------------------------------------------------------- 1 | @mixin transition() { 2 | transition: 0.2s ease-in-out; 3 | } 4 | 5 | @mixin transition-hover() { 6 | transition: 0.5s ease-in-out; 7 | } 8 | -------------------------------------------------------------------------------- /client/src/utils/bytes.ts: -------------------------------------------------------------------------------- 1 | // Program data is stored as little-endian 2 | // [0x1, 0x0] -> id #0, [0x0, 0x1] -> id #8 3 | export function programDataToIds(bytes: Uint8Array): Array { 4 | const ids = new Array(); 5 | bytes.forEach((byte, i) => { 6 | for (let j = 7; j >= 0; j--) { 7 | if ((byte & (1 << j)) === 1 << j) { 8 | ids.push(8 * i + (7 - j)); 9 | } 10 | } 11 | }); 12 | return ids; 13 | } 14 | 15 | // Instruction data uses big-endian 16 | // id #0 -> [0x0, 0x0], id #256 -> [0x1, 0x0] 17 | const MAX_ID = Math.pow(2, 16) - 1; 18 | export function instructionDataFromId(id: number): Uint8Array { 19 | if (id > MAX_ID || id < 0 || !Number.isInteger(id)) { 20 | throw new Error("invalid id"); 21 | } 22 | 23 | const bytes = new Uint8Array(2); 24 | bytes[0] = Math.floor(id / 256); 25 | bytes[1] = id % 256; 26 | return bytes; 27 | } 28 | 29 | export function xor(a: Uint8Array, b: Uint8Array): Uint8Array { 30 | if (a.length !== b.length) throw new Error("bytes are not the same length"); 31 | const bytes = new Uint8Array(a); 32 | for (let i = 0; i < b.length; i++) { 33 | bytes[i] ^= b[i]; 34 | } 35 | return bytes; 36 | } 37 | -------------------------------------------------------------------------------- /client/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { Keypair } from "@solana/web3.js"; 2 | // import * as Sentry from "@sentry/react"; 3 | 4 | export function sleep(ms: number): Promise { 5 | return new Promise((resolve) => setTimeout(resolve, ms)); 6 | } 7 | 8 | export function reportError(err: unknown, context: string) { 9 | if (err instanceof Error) { 10 | console.error(context, err); 11 | } 12 | // if (process.env.NODE_ENV === "production") { 13 | // const query = new URLSearchParams(window.location.search); 14 | // if (query.get("cluster") === "custom") return; 15 | // Sentry.captureException(err, { 16 | // tags: { context }, 17 | // }); 18 | // } 19 | } 20 | 21 | export function isLocalHost() { 22 | return window.location.hostname === "localhost"; 23 | } 24 | 25 | export const getLocalStorageKeypair = (key: string): Keypair => { 26 | const base64Keypair = window.localStorage.getItem(key); 27 | if (base64Keypair) { 28 | return Keypair.fromSecretKey(Buffer.from(base64Keypair, "base64")); 29 | } else { 30 | const keypair = new Keypair(); 31 | window.localStorage.setItem( 32 | key, 33 | Buffer.from(keypair.secretKey).toString("base64") 34 | ); 35 | return keypair; 36 | } 37 | }; 38 | 39 | export const getFeePayers = (num: number) => { 40 | const accounts = []; 41 | for (let i = 0; i < num; i++) { 42 | accounts.push(getLocalStorageKeypair(`feePayerKey${i + 1}`)); 43 | } 44 | return accounts; 45 | }; 46 | -------------------------------------------------------------------------------- /client/src/worker-loader.d.ts: -------------------------------------------------------------------------------- 1 | declare module "worker-loader!*" { 2 | class WebpackWorker extends Worker { 3 | constructor(); 4 | } 5 | 6 | export default WebpackWorker; 7 | } 8 | -------------------------------------------------------------------------------- /client/src/workers/create-transaction-rpc.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-webpack-loader-syntax 2 | import CreateTransactionWorker from "worker-loader!./create-transaction-worker-script"; 3 | 4 | import { Blockhash } from "@solana/web3.js"; 5 | 6 | export interface CreateTransactionMessage { 7 | trackingId: number; 8 | blockhash: Blockhash; 9 | programId: string; 10 | bitId: number; 11 | feeAccountSecretKey: Uint8Array; 12 | programDataAccount: string; 13 | computeUnitPrice?: number; 14 | extraWriteAccount?: string; 15 | } 16 | 17 | export interface CreateTransactionResponseMessage { 18 | trackingId: number; 19 | signature: Buffer; 20 | serializedTransaction: Buffer; 21 | } 22 | 23 | export interface CreateTransactionErrorMessage { 24 | trackingId: string; 25 | error: Error; 26 | } 27 | 28 | export class CreateTransactionRPC { 29 | private worker: CreateTransactionWorker; 30 | 31 | private callbacks: { [trackingId: string]: Function[] } = {}; 32 | 33 | constructor() { 34 | this.worker = new CreateTransactionWorker(); 35 | this.worker.onmessage = this.handleMessages.bind(this); 36 | } 37 | 38 | handleMessages(event: MessageEvent) { 39 | let message = event.data; 40 | 41 | if (message.trackingId in this.callbacks) { 42 | let callbacks = this.callbacks[message.trackingId]; 43 | delete this.callbacks[message.trackingId]; 44 | 45 | if ("error" in message) { 46 | callbacks[1](message.error); 47 | return; 48 | } 49 | 50 | callbacks[0](message); 51 | } 52 | } 53 | 54 | createTransaction( 55 | message: CreateTransactionMessage 56 | ): Promise { 57 | return new Promise((resolve, reject) => { 58 | this.callbacks[message.trackingId] = [resolve, reject]; 59 | this.worker.postMessage(message); 60 | }); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /client/src/workers/create-transaction-worker-script.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Transaction, 3 | PublicKey, 4 | Keypair, 5 | ComputeBudgetProgram, 6 | } from "@solana/web3.js"; 7 | import * as Bytes from "utils/bytes"; 8 | import { CreateTransactionMessage } from "./create-transaction-rpc"; 9 | 10 | const self: any = globalThis; 11 | 12 | function createTransaction(message: CreateTransactionMessage) { 13 | const { 14 | trackingId, 15 | blockhash, 16 | programId, 17 | bitId, 18 | feeAccountSecretKey, 19 | programDataAccount, 20 | computeUnitPrice, 21 | extraWriteAccount, 22 | } = message; 23 | 24 | const transaction = new Transaction(); 25 | if (computeUnitPrice) { 26 | const units = 1000; 27 | const MICRO_LAMPORTS_PER_LAMPORT = 1_000_000; 28 | const additionalFee = Math.ceil( 29 | (computeUnitPrice * units) / MICRO_LAMPORTS_PER_LAMPORT 30 | ); 31 | transaction.add( 32 | ComputeBudgetProgram.requestUnits({ 33 | units, 34 | additionalFee, 35 | }) 36 | ); 37 | } 38 | const breakAccountInputs = [ 39 | { 40 | pubkey: new PublicKey(programDataAccount), 41 | isWritable: true, 42 | isSigner: false, 43 | }, 44 | ]; 45 | if (extraWriteAccount) { 46 | breakAccountInputs.push({ 47 | pubkey: new PublicKey(extraWriteAccount), 48 | isWritable: true, 49 | isSigner: false, 50 | }); 51 | } 52 | transaction.add({ 53 | keys: breakAccountInputs, 54 | programId: new PublicKey(programId), 55 | data: Buffer.from(Bytes.instructionDataFromId(bitId)), 56 | }); 57 | transaction.recentBlockhash = blockhash; 58 | transaction.sign(Keypair.fromSecretKey(feeAccountSecretKey)); 59 | 60 | const signatureBuffer = transaction.signature; 61 | 62 | self.postMessage({ 63 | trackingId: trackingId, 64 | signature: signatureBuffer, 65 | serializedTransaction: transaction.serialize(), 66 | }); 67 | } 68 | 69 | self.onmessage = (event: any) => { 70 | const message = event.data; 71 | 72 | try { 73 | createTransaction(message); 74 | } catch (error) { 75 | self.postMessage({ 76 | trackingId: message.trackingId, 77 | error: error, 78 | }); 79 | } 80 | }; 81 | -------------------------------------------------------------------------------- /client/svgTransform.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | process(): string { 3 | return "module.exports = {};"; 4 | }, 5 | getCacheKey(): string { 6 | // The output is always the same. 7 | return "svgTransform"; 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext", "webworker"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "downlevelIteration": true, 8 | "esModuleInterop": true, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | "baseUrl": "src", 19 | "noFallthroughCasesInSwitch": true 20 | }, 21 | "include": ["src"] 22 | } 23 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@solana/break-app", 3 | "lockfileVersion": 2, 4 | "requires": true, 5 | "packages": { 6 | "": { 7 | "name": "@solana/break-app", 8 | "hasInstallScript": true 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@solana/break-app", 3 | "cacheDirectories": [ 4 | "client/node_modules", 5 | "server/node_modules" 6 | ], 7 | "scripts": { 8 | "client:build": "./package.sh client run build", 9 | "client:install": "./package.sh client install", 10 | "server:build": "./package.sh server run build", 11 | "server:start": "./package.sh server run start", 12 | "server:install": "./package.sh server install", 13 | "program:build": "./program/do.sh build", 14 | "program:clean": "./program/do.sh clean", 15 | "start": "npm run server:start", 16 | "build": "./package.sh both run build", 17 | "lint": "./package.sh both run lint", 18 | "lint:fix": "./package.sh both run lint:fix", 19 | "format": "./package.sh both run format", 20 | "format:fix": "./package.sh both run format:fix", 21 | "postinstall": "./package.sh both install" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /package.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | dir="$1" 4 | shift 5 | if [ "$dir" = "both" ]; then 6 | set -ex 7 | (cd client && npm "$@" ) 8 | (cd server && npm "$@" ) 9 | else 10 | set -ex 11 | (cd $dir && npm "$@" ) 12 | fi 13 | -------------------------------------------------------------------------------- /program/.gitignore: -------------------------------------------------------------------------------- 1 | # dist is intentionally not ignored 2 | target 3 | -------------------------------------------------------------------------------- /program/Cargo.toml: -------------------------------------------------------------------------------- 1 | # Note: This crate must be built using build.sh 2 | 3 | [package] 4 | name = "break-solana-program" 5 | version = "0.1.0" 6 | description = "Break Solana program" 7 | authors = ["Solana Maintainers "] 8 | repository = "https://github.com/solana-labs/break" 9 | license = "Apache-2.0" 10 | homepage = "https://break.solana.com/" 11 | edition = "2018" 12 | 13 | [features] 14 | no-entrypoint = [] 15 | 16 | [dependencies] 17 | solana-program = "=1.6.6" 18 | 19 | [lib] 20 | crate-type = ["cdylib", "lib"] 21 | name = "break_solana_program" 22 | -------------------------------------------------------------------------------- /program/Xargo.toml: -------------------------------------------------------------------------------- 1 | [target.bpfel-unknown-unknown.dependencies.std] 2 | features = [] -------------------------------------------------------------------------------- /program/dist/break_solana_program.so: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solana-labs/break/c97989858498f056954649c0f8ae03862a805a4d/program/dist/break_solana_program.so -------------------------------------------------------------------------------- /program/src/lib.rs: -------------------------------------------------------------------------------- 1 | use solana_program::{ 2 | account_info::AccountInfo, entrypoint, entrypoint::ProgramResult, pubkey::Pubkey, 3 | }; 4 | 5 | entrypoint!(process_instruction); 6 | fn process_instruction<'a>( 7 | _program_id: &Pubkey, 8 | accounts: &'a [AccountInfo<'a>], 9 | instruction_data: &[u8], 10 | ) -> ProgramResult { 11 | // Assume a writable account is at index 0 12 | let mut account_data = accounts[0].try_borrow_mut_data()?; 13 | 14 | // xor with the account data using byte and bit from ix data 15 | let index = u16::from_be_bytes([instruction_data[0], instruction_data[1]]); 16 | let byte = index >> 3; 17 | let bit = (index & 0x7) as u8; 18 | account_data[byte as usize] ^= 1 << (7 - bit); 19 | 20 | Ok(()) 21 | } 22 | 23 | // Sanity tests 24 | #[cfg(test)] 25 | mod test { 26 | use super::*; 27 | use solana_program::clock::Epoch; 28 | 29 | #[test] 30 | fn test_xor() { 31 | let program_id = Pubkey::default(); 32 | let key = Pubkey::default(); 33 | let mut lamports = 0; 34 | let mut data = vec![0; 4]; 35 | let owner = Pubkey::default(); 36 | let account = AccountInfo::new( 37 | &key, 38 | false, 39 | true, 40 | &mut lamports, 41 | &mut data, 42 | &owner, 43 | false, 44 | Epoch::default(), 45 | ); 46 | 47 | let accounts = vec![account]; 48 | 49 | process_instruction(&program_id, &accounts, &[1, 1, 1, 1]).unwrap(); 50 | assert_eq!(*accounts[0].data.borrow(), &[1, 1, 1, 1]); 51 | 52 | process_instruction(&program_id, &accounts, &[1, 1, 1, 1]).unwrap(); 53 | assert_eq!(*accounts[0].data.borrow(), &[0, 0, 0, 0]); 54 | } 55 | 56 | #[test] 57 | #[should_panic] 58 | fn test_bad_instruction_data() { 59 | let program_id = Pubkey::default(); 60 | let key = Pubkey::default(); 61 | let mut lamports = 0; 62 | let mut data = vec![0; 2]; 63 | let owner = Pubkey::default(); 64 | let account = AccountInfo::new( 65 | &key, 66 | false, 67 | true, 68 | &mut lamports, 69 | &mut data, 70 | &owner, 71 | false, 72 | Epoch::default(), 73 | ); 74 | 75 | let accounts = vec![account]; 76 | process_instruction(&program_id, &accounts, &[0, 1, 2]).unwrap(); 77 | } 78 | 79 | #[test] 80 | #[should_panic] 81 | fn test_bad_account() { 82 | let program_id = Pubkey::default(); 83 | let accounts = vec![]; 84 | process_instruction(&program_id, &accounts, &[0, 1, 2, 4]).unwrap(); 85 | } 86 | #[test] 87 | #[should_panic] 88 | fn test_bad_account_data() { 89 | let program_id = Pubkey::default(); 90 | let key = Pubkey::default(); 91 | let mut lamports = 0; 92 | let mut data = vec![0; 3]; 93 | let owner = Pubkey::default(); 94 | let account = AccountInfo::new( 95 | &key, 96 | false, 97 | true, 98 | &mut lamports, 99 | &mut data, 100 | &owner, 101 | false, 102 | Epoch::default(), 103 | ); 104 | 105 | let accounts = vec![account]; 106 | process_instruction(&program_id, &accounts, &[0, 1, 2, 4]).unwrap(); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /server/.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | webpack.config.js 3 | src/types 4 | -------------------------------------------------------------------------------- /server/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:@typescript-eslint/eslint-recommended", 9 | "plugin:@typescript-eslint/recommended" 10 | ], 11 | "globals": { 12 | "Atomics": "readonly", 13 | "SharedArrayBuffer": "readonly" 14 | }, 15 | "parser": "@typescript-eslint/parser", 16 | "parserOptions": { 17 | "ecmaVersion": 2018, 18 | "sourceType": "module" 19 | }, 20 | "plugins": ["@typescript-eslint"], 21 | "rules": {} 22 | } 23 | -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | -------------------------------------------------------------------------------- /server/.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | package-lock.json 3 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@solana/break-backend", 3 | "private": true, 4 | "version": "1.0.0", 5 | "description": "Try to break Solana's network", 6 | "author": "Solana Maintainers ", 7 | "license": "MIT", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/solana-labs/break" 11 | }, 12 | "scripts": { 13 | "build": "webpack --mode production", 14 | "test": "echo 'no test specified'", 15 | "start": "node ./dist/server.js", 16 | "start:dev": "ts-node src/index.ts", 17 | "build:dev": "webpack --mode development", 18 | "format": "prettier --check \"**/*.{js,jsx,ts,tsx,json,md}\"", 19 | "format:fix": "prettier --write \"**/*.{js,jsx,ts,tsx,json,md}\"", 20 | "lint": "set -ex; eslint --ext .js,.ts,.tsx .", 21 | "lint:fix": "npm run lint -- --fix" 22 | }, 23 | "testnetDefaultChannel": "edge", 24 | "dependencies": { 25 | "@sentry/node": "^7.1.1", 26 | "@sentry/tracing": "^7.0.0", 27 | "@solana/web3.js": "^1.43.6", 28 | "@types/cors": "^2.8.10", 29 | "@types/express": "^4.17.12", 30 | "@types/node": "^17.0.41", 31 | "@types/ws": "^8.5.3", 32 | "@typescript-eslint/eslint-plugin": "^4.33.0", 33 | "@typescript-eslint/parser": "^4.33.0", 34 | "bs58": "^5.0.0", 35 | "cors": "^2.8.5", 36 | "eslint": "^7.27.0", 37 | "express": "^4.18.1", 38 | "prettier": "^2.6.2", 39 | "ts-loader": "^9.3.0", 40 | "ts-node": "^10.8.1", 41 | "typescript": "^4.7.3", 42 | "webpack": "^5.73.0", 43 | "webpack-cli": "^4.9.2", 44 | "webpack-node-externals": "^3.0.0", 45 | "ws": "^8.7.0" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /server/src/api.ts: -------------------------------------------------------------------------------- 1 | import { Connection } from "@solana/web3.js"; 2 | import { cluster, url, urlTls } from "./urls"; 3 | import { PROGRAM_ID } from "./program"; 4 | import TpuProxy from "./tpu_proxy"; 5 | import WebSocketServer from "./websocket"; 6 | import { Express } from "express"; 7 | import http from "http"; 8 | 9 | export default class ApiServer { 10 | static async start(app: Express, httpServer: http.Server): Promise { 11 | const connection = new Connection(url, "confirmed"); 12 | const tpuProxy = await TpuProxy.create(connection); 13 | WebSocketServer.start(httpServer, tpuProxy); 14 | 15 | await tpuProxy.connect(); 16 | 17 | const programId = PROGRAM_ID?.toBase58(); 18 | if (!programId) { 19 | throw new Error("Internal error: program id is missing"); 20 | } 21 | 22 | app.post("/init", async (req, res) => { 23 | res 24 | .send( 25 | JSON.stringify({ 26 | programId, 27 | clusterUrl: urlTls, 28 | cluster, 29 | paymentRequired: process.env.REQUIRE_PAYMENT === "true", 30 | }) 31 | ) 32 | .end(); 33 | }); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /server/src/available_nodes.ts: -------------------------------------------------------------------------------- 1 | import { Connection } from "@solana/web3.js"; 2 | import { endlessRetry } from "./utils"; 3 | 4 | type NodeAddress = string; 5 | type TpuEndpoint = string; 6 | type AvailableNodes = Map; 7 | type DelinquentNodes = Set; 8 | 9 | // Polls cluster to determine which nodes are available 10 | export default class AvailableNodesService { 11 | refreshing = false; 12 | 13 | constructor( 14 | private connection: Connection, 15 | public nodes: AvailableNodes, 16 | public delinquents: DelinquentNodes 17 | ) { 18 | // Refresh every 5min in case nodes leave the cluster or change port configuration 19 | setInterval(() => this.refresh(), 5 * 60 * 1000); 20 | } 21 | 22 | static start = async ( 23 | connection: Connection 24 | ): Promise => { 25 | const nodes = await AvailableNodesService.getAvailableNodes(connection); 26 | return new AvailableNodesService(connection, nodes, new Set()); 27 | }; 28 | 29 | private static getAvailableNodes = async ( 30 | connection: Connection 31 | ): Promise => { 32 | const availableNodes = new Map(); 33 | const nodes = await endlessRetry("getClusterNodes", async () => 34 | connection.getClusterNodes() 35 | ); 36 | for (const node of nodes) { 37 | if (node.tpu) { 38 | availableNodes.set(node.pubkey, node.tpu); 39 | } 40 | } 41 | return availableNodes; 42 | }; 43 | 44 | private refresh = async (): Promise => { 45 | if (this.refreshing) return; 46 | this.refreshing = true; 47 | this.nodes = await AvailableNodesService.getAvailableNodes(this.connection); 48 | this.delinquents.clear(); 49 | this.refreshing = false; 50 | }; 51 | } 52 | -------------------------------------------------------------------------------- /server/src/index.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import http from "http"; 3 | import cors from "cors"; 4 | import path from "path"; 5 | import ApiServer from "./api"; 6 | import * as Sentry from "@sentry/node"; 7 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 8 | import * as Tracing from "@sentry/tracing"; 9 | 10 | Sentry.init({ 11 | dsn: "https://f74dafc67c914776b018c3be136bca19@o434108.ingest.sentry.io/5411826", 12 | // send 10% of all errors to Sentry 13 | tracesSampleRate: 0.1, 14 | }); 15 | 16 | const app = express(); 17 | 18 | // Redirect to https on Heroku 19 | if ( 20 | process.env.NODE_ENV === "production" && 21 | process.env.FORCE_HTTPS !== undefined 22 | ) { 23 | app.use((req, res, next) => { 24 | if (req.header("x-forwarded-proto") !== "https") { 25 | res.redirect(`https://${req.header("host")}${req.url}`); 26 | } else { 27 | next(); 28 | } 29 | }); 30 | } 31 | 32 | const rootPath = path.join(__dirname, "..", ".."); 33 | const staticPath = path.join(rootPath, "client", "build"); 34 | console.log(`Serving static files from: ${staticPath}`); 35 | app.use("/", express.static(staticPath)); 36 | app.get("/*", (req, res) => { 37 | res.sendFile(path.join(staticPath, "/index.html")); 38 | }); 39 | 40 | const httpServer = http.createServer(app); 41 | const port = process.env.PORT || 8080; 42 | httpServer.listen(port); 43 | console.log(`Server listening on port: ${port}`); 44 | 45 | if (!process.env.DISABLE_API) { 46 | app.use(cors()); 47 | app.use(express.json()); 48 | ApiServer.start(app, httpServer); 49 | } 50 | -------------------------------------------------------------------------------- /server/src/leader_schedule.ts: -------------------------------------------------------------------------------- 1 | import { Connection } from "@solana/web3.js"; 2 | import { endlessRetry, reportError } from "./utils"; 3 | 4 | // Number of upcoming slots to include when building upcoming node set 5 | export const UPCOMING_SLOT_SEARCH = parseInt( 6 | process.env.LEADER_SLOT_FANOUT || "40" 7 | ); 8 | 9 | // Number of past slots to include when building upcoming node set 10 | export const PAST_SLOT_SEARCH = 4; 11 | 12 | // Updates the leader schedule every epoch and provides a set of the 13 | // upcoming nodes in the schedule 14 | export default class LeaderScheduleService { 15 | refreshing = false; 16 | 17 | constructor( 18 | private connection: Connection, 19 | private leaderAddresses: Array, 20 | private scheduleFirstSlot: number 21 | ) {} 22 | 23 | static start = async ( 24 | connection: Connection, 25 | currentSlot: number 26 | ): Promise => { 27 | const leaderService = new LeaderScheduleService( 28 | connection, 29 | [], 30 | currentSlot 31 | ); 32 | 33 | leaderService.leaderAddresses = await endlessRetry("getSlotLeaders", () => 34 | leaderService.fetchLeaders(currentSlot) 35 | ); 36 | 37 | return leaderService; 38 | }; 39 | 40 | private async fetchLeaders(startSlot: number): Promise> { 41 | const leaders = await this.connection.getSlotLeaders( 42 | startSlot, 43 | 2 * UPCOMING_SLOT_SEARCH 44 | ); 45 | return leaders.map((l) => l.toBase58()); 46 | } 47 | 48 | getFirstSlot = (): number => { 49 | return this.scheduleFirstSlot; 50 | }; 51 | 52 | getSlotLeader = (slot: number): string | null => { 53 | const firstSlot = this.scheduleFirstSlot; 54 | const lastSlot = this.lastSlot(); 55 | if (slot < firstSlot) { 56 | console.error( 57 | `getSlotLeader failed: Tried to get ${slot} before first schedule slot ${firstSlot}` 58 | ); 59 | } else if (slot > lastSlot) { 60 | console.error( 61 | `getSlotLeader failed: Tried to get ${slot} after last schedule slot ${lastSlot}` 62 | ); 63 | } else { 64 | return this.leaderAddresses[slot - this.scheduleFirstSlot]; 65 | } 66 | return null; 67 | }; 68 | 69 | private lastSlot = (): number => { 70 | return this.scheduleFirstSlot + this.leaderAddresses.length - 1; 71 | }; 72 | 73 | shouldRefresh = (currentSlot: number): boolean => { 74 | const shouldRefreshAt = this.lastSlot() - UPCOMING_SLOT_SEARCH; 75 | return currentSlot >= shouldRefreshAt; 76 | }; 77 | 78 | refresh = async (currentSlot: number): Promise => { 79 | if (this.refreshing) return; 80 | this.refreshing = true; 81 | try { 82 | const firstSlot = Math.max(0, currentSlot - PAST_SLOT_SEARCH); 83 | const leaderAddresses = await this.fetchLeaders(firstSlot); 84 | this.scheduleFirstSlot = firstSlot; 85 | this.leaderAddresses = leaderAddresses; 86 | } catch (err) { 87 | reportError(err, "failed to refresh slot leaders"); 88 | } finally { 89 | this.refreshing = false; 90 | } 91 | }; 92 | } 93 | -------------------------------------------------------------------------------- /server/src/leader_tracker.ts: -------------------------------------------------------------------------------- 1 | import { Connection } from "@solana/web3.js"; 2 | 3 | const MAX_RECENT_SLOTS_LENGTH = 12; 4 | 5 | // 48 chosen because it's unlikely that 12 leaders in a row will miss their slots 6 | const MAX_SLOT_SKIP_DISTANCE = 48; 7 | 8 | export default class LeaderTrackerService { 9 | private recentSlots: Array = []; 10 | 11 | constructor( 12 | private connection: Connection, 13 | private currentSlot: number, 14 | callback: (slot: number) => Promise 15 | ) { 16 | this.recentSlots.push(currentSlot); 17 | 18 | let receivedShredNotification = false; 19 | let processingCallback = false; 20 | this.connection.onSlotUpdate((update) => { 21 | const previousCurrentSlot = this.currentSlot; 22 | let newCurrentSlot = this.currentSlot; 23 | switch (update.type) { 24 | case "firstShredReceived": { 25 | receivedShredNotification = true; 26 | newCurrentSlot = this.updateRecentSlots(update.slot); 27 | break; 28 | } 29 | case "completed": { 30 | receivedShredNotification = true; 31 | newCurrentSlot = this.updateRecentSlots(update.slot + 1); 32 | break; 33 | } 34 | case "createdBank": { 35 | // Fallback to bank created slot updates if no shred notifications 36 | // are received (ie. connected to single node cluster leader). 37 | if (!receivedShredNotification) { 38 | newCurrentSlot = this.updateRecentSlots(update.slot); 39 | } 40 | break; 41 | } 42 | } 43 | 44 | if (newCurrentSlot != previousCurrentSlot) { 45 | // console.debug(`Leader tracker detected new slot: ${newCurrentSlot}`); 46 | if (!processingCallback) { 47 | processingCallback = true; 48 | callback(newCurrentSlot) 49 | .then(() => { 50 | // console.debug( 51 | // `Leader tracker handled new slot: ${newCurrentSlot}` 52 | // ); 53 | }) 54 | .catch((err) => { 55 | console.error("Failed to handle new slot", err); 56 | }) 57 | .finally(() => { 58 | processingCallback = false; 59 | }); 60 | } 61 | } 62 | }); 63 | } 64 | 65 | private updateRecentSlots = (slot: number) => { 66 | this.recentSlots.push(slot); 67 | while (this.recentSlots.length > MAX_RECENT_SLOTS_LENGTH) { 68 | this.recentSlots.shift(); 69 | } 70 | 71 | // After updating recent slots, calculate the current slot 72 | 73 | const recentSlots = this.recentSlots.slice(0); 74 | recentSlots.sort(); 75 | 76 | // Validators can broadcast invalid blocks that are far in the future 77 | // so check if the current slot is in line with the recent progression. 78 | const maxIndex = recentSlots.length - 1; 79 | const medianIndex = Math.floor(maxIndex / 2); 80 | const medianRecentSlot = recentSlots[medianIndex]; 81 | const expectedCurrentSlot = medianRecentSlot + (maxIndex - medianIndex); 82 | const maxReasonableCurrentSlot = 83 | expectedCurrentSlot + MAX_SLOT_SKIP_DISTANCE; 84 | 85 | // Return the highest slot that doesn't exceed what we believe is a 86 | // reasonable slot. 87 | recentSlots.reverse(); 88 | for (const slot of recentSlots) { 89 | if (slot <= maxReasonableCurrentSlot) { 90 | return slot; 91 | } 92 | } 93 | 94 | // This fall back is impossible 95 | return this.currentSlot; 96 | }; 97 | } 98 | -------------------------------------------------------------------------------- /server/src/program.ts: -------------------------------------------------------------------------------- 1 | import { Keypair, PublicKey } from "@solana/web3.js"; 2 | import path from "path"; 3 | import fs from "fs"; 4 | 5 | const DEPLOYED_PROGRAM_ADDRESS = process.env.DEPLOYED_PROGRAM_ADDRESS; 6 | 7 | const PROGRAM_KEYPAIR_PATH = path.resolve( 8 | "..", 9 | "program", 10 | "target", 11 | "deploy", 12 | "break_solana_program-keypair.json" 13 | ); 14 | 15 | export const PROGRAM_ID = (() => { 16 | if (DEPLOYED_PROGRAM_ADDRESS) { 17 | return new PublicKey(DEPLOYED_PROGRAM_ADDRESS); 18 | } else if (!process.env.DISABLE_API) { 19 | return readKeypairFromFile(PROGRAM_KEYPAIR_PATH).publicKey; 20 | } 21 | })(); 22 | 23 | /** 24 | * Create a Keypair from a keypair file 25 | */ 26 | function readKeypairFromFile(filePath: string): Keypair { 27 | const keypairString = fs.readFileSync(filePath, { encoding: "utf8" }); 28 | const keypairBuffer = Buffer.from(JSON.parse(keypairString)); 29 | return Keypair.fromSecretKey(keypairBuffer); 30 | } 31 | -------------------------------------------------------------------------------- /server/src/urls.ts: -------------------------------------------------------------------------------- 1 | // To connect to a public cluster, set `export LIVE=1` in your 2 | // environment. By default, `LIVE=1` will connect to the devnet cluster. 3 | 4 | import { clusterApiUrl, Cluster } from "@solana/web3.js"; 5 | 6 | function chooseCluster(): Cluster | undefined { 7 | if (!process.env.LIVE) return; 8 | switch (process.env.CLUSTER) { 9 | case "devnet": 10 | case "testnet": 11 | case "mainnet-beta": { 12 | return process.env.CLUSTER; 13 | } 14 | } 15 | return "devnet"; 16 | } 17 | 18 | export const cluster = chooseCluster(); 19 | 20 | export const url = 21 | process.env.RPC_URL || 22 | (process.env.LIVE ? clusterApiUrl(cluster, false) : "http://localhost:8899"); 23 | 24 | export const urlTls = 25 | process.env.RPC_URL || 26 | (process.env.LIVE ? clusterApiUrl(cluster, true) : "http://localhost:8899"); 27 | -------------------------------------------------------------------------------- /server/src/utils.ts: -------------------------------------------------------------------------------- 1 | import * as Sentry from "@sentry/node"; 2 | 3 | export function sleep(ms: number): Promise { 4 | return new Promise((resolve) => setTimeout(resolve, ms)); 5 | } 6 | 7 | export function notUndefined(x: T | undefined): x is T { 8 | return x !== undefined; 9 | } 10 | 11 | export async function endlessRetry( 12 | name: string, 13 | call: () => Promise 14 | ): Promise { 15 | let result: T | undefined; 16 | while (result == undefined) { 17 | try { 18 | console.log(name, "fetching"); 19 | result = await call(); 20 | } catch (err) { 21 | reportError(err, `Request ${name} failed, retrying`); 22 | await sleep(1000); 23 | } 24 | } 25 | console.log(name, "fetched!"); 26 | return result; 27 | } 28 | 29 | const cluster = process.env.CLUSTER; 30 | export function reportError(err: unknown, context: string): void { 31 | if (err instanceof Error) { 32 | console.error(context, err); 33 | if (process.env.NODE_ENV === "production") { 34 | Sentry.captureException(err, { 35 | tags: { context, cluster }, 36 | }); 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /server/src/websocket.ts: -------------------------------------------------------------------------------- 1 | import WebSocket from "ws"; 2 | import http from "http"; 3 | import TpuProxy from "./tpu_proxy"; 4 | 5 | // eslint-disable-next-line @typescript-eslint/no-empty-function 6 | function noop() {} 7 | 8 | export default class WebSocketServer { 9 | static start(httpServer: http.Server, tpuProxy: TpuProxy): void { 10 | // Start websocket server 11 | let activeUsers = 0; 12 | const wss = new WebSocket.Server({ server: httpServer }); 13 | wss.on("connection", function connection(ws) { 14 | let isAlive = true; 15 | let useRpc = true; 16 | let rpcOverride: string; 17 | 18 | function heartbeat() { 19 | isAlive = true; 20 | } 21 | 22 | const interval = setInterval(function ping() { 23 | if (isAlive === false) return ws.terminate(); 24 | isAlive = false; 25 | ws.ping(noop); 26 | }, 30000); 27 | 28 | activeUsers++; 29 | ws.on("close", () => { 30 | clearInterval(interval); 31 | activeUsers--; 32 | }); 33 | ws.on("message", (data: Buffer, isBinary: boolean) => { 34 | const message = isBinary ? data : data.toString(); 35 | if (typeof message === "string") { 36 | if (message === "tpu") { 37 | console.log("Client switched to TPU mode"); 38 | useRpc = false; 39 | } else if (message === "rpc") { 40 | console.log("Client switched to RPC mode"); 41 | useRpc = true; 42 | } else { 43 | try { 44 | const rpcEndpoint = new URL(message); 45 | rpcOverride = rpcEndpoint.toString(); 46 | console.log("Client overrode RPC endpoint to", rpcEndpoint.href); 47 | } catch (err) { 48 | console.warn("Ignoring client message", message); 49 | } 50 | } 51 | } else { 52 | tpuProxy.sendRawTransaction( 53 | message, 54 | useRpc, 55 | rpcOverride, 56 | (message: string) => { 57 | ws.send(message); 58 | } 59 | ); 60 | } 61 | }); 62 | ws.on("pong", heartbeat); 63 | }); 64 | 65 | // Start active user broadcast loop 66 | setInterval(() => { 67 | wss.clients.forEach((client) => { 68 | if (client.readyState === WebSocket.OPEN) { 69 | client.send(JSON.stringify({ type: "heartbeat", activeUsers })); 70 | } 71 | }); 72 | }, 1000); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "esModuleInterop": true, 5 | "downlevelIteration": true, 6 | "module": "commonjs", 7 | "moduleResolution": "node", 8 | "baseUrl": "./", 9 | "typeRoots": ["./src/types", "./node_modules/@types"], 10 | "paths": { 11 | "@/*": ["./src/*"] 12 | } 13 | }, 14 | "include": ["src"] 15 | } 16 | -------------------------------------------------------------------------------- /server/webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require("webpack"); 2 | const path = require("path"); 3 | const nodeExternals = require("webpack-node-externals"); 4 | 5 | module.exports = (env, argv) => { 6 | let mode = argv.mode; 7 | let config = { 8 | mode, 9 | target: "node", 10 | entry: ["./src/index.ts"], 11 | node: { 12 | __dirname: false, 13 | }, 14 | output: { 15 | path: path.resolve(__dirname, "dist"), 16 | filename: "server.js", 17 | }, 18 | module: { 19 | rules: [ 20 | { 21 | test: /\.ts$/, 22 | use: "ts-loader", 23 | exclude: /node_modules/, 24 | }, 25 | ], 26 | }, 27 | resolve: { 28 | extensions: [".ts", ".js", ".json"], 29 | }, 30 | externals: [nodeExternals()], 31 | }; 32 | 33 | if (mode === "development") { 34 | config.watch = true; 35 | } 36 | 37 | return config; 38 | }; 39 | --------------------------------------------------------------------------------