├── .babelrc ├── .editorconfig ├── .env.example ├── .env.production ├── .env.staging ├── .github └── workflows │ ├── ci.yml │ ├── release-production.yml │ └── release-staging.yml ├── .gitignore ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── LICENCE ├── README.md ├── docs └── help-widget-diagrams.drawio ├── html-dev ├── favicon.ico └── index.html ├── html-production ├── favicon.ico └── index.html ├── html-staging ├── favicon.ico └── index.html ├── jest.config.ts ├── package.json ├── src ├── App.tsx ├── AppContext.tsx ├── components │ ├── Header.tsx │ ├── Loading.tsx │ ├── NumberInputWithSlider.tsx │ ├── ProgressModal.tsx │ ├── Tooltip.tsx │ └── wallet-adapter │ │ ├── ConnectionProvider.tsx │ │ ├── WalletProvider.tsx │ │ ├── errors.ts │ │ ├── ui │ │ ├── Button.tsx │ │ ├── Collapse.tsx │ │ ├── WalletConnectButton.tsx │ │ ├── WalletIcon.tsx │ │ ├── WalletListItem.tsx │ │ ├── WalletModal.tsx │ │ ├── WalletModalButton.tsx │ │ ├── WalletModalProvider.tsx │ │ └── useWalletModal.ts │ │ ├── useConnection.ts │ │ ├── useLocalStorage.ts │ │ └── useWallet.ts ├── index.ts ├── layout │ ├── Main.tsx │ └── Router.tsx ├── libs │ ├── env.ts │ ├── program.ts │ ├── rc-input-number │ │ ├── InputNumber.tsx │ │ ├── hooks │ │ │ ├── useCursor.ts │ │ │ └── useFrame.ts │ │ ├── index.ts │ │ └── utils │ │ │ ├── MiniDecimal.ts │ │ │ ├── numberUtil.ts │ │ │ └── supportUtil.ts │ ├── send.ts │ └── utils.ts ├── loader.ts ├── main.css ├── models.ts ├── routes │ ├── Actions.tsx │ ├── Stake.tsx │ └── Unstake.tsx └── validations │ └── subscriptions.ts ├── tailwind.config.js ├── test ├── common.ts └── loader.spec.ts ├── tsconfig.json ├── tslint.json ├── typings.d.ts ├── webpack.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/env", ["@babel/typescript", { "jsxPragma": "h" }]], 3 | "plugins": [["@babel/transform-react-jsx", { "pragma": "h" }]] 4 | } 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | charset = utf-8 6 | trim_trailing_whitespace = true 7 | insert_final_newline = true 8 | indent_style = space 9 | indent_size = 2 10 | 11 | [*.{ts,tsx}] 12 | quote_type = double 13 | 14 | [*.{css,json,yml}] 15 | indent_size = 2 16 | 17 | [*.md] 18 | trim_trailing_whitespace = false 19 | 20 | [webpack.config.js] 21 | indent_size = 2 22 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | PROGRAM_ID= 2 | TOKEN_MINT= 3 | SOLANA_RPC_URL=https://api.devnet.solana.com 4 | SOLANA_NETWORK=devnet 5 | UNSTAKE_BASE_URL=http://localhost:3001/creators 6 | REWARDS_BASE_URL=https://localhost:3001/rewards 7 | GET_ACS_URL=http://localhost:3001/get-acs 8 | -------------------------------------------------------------------------------- /.env.production: -------------------------------------------------------------------------------- 1 | PROGRAM_ID=6HW8dXjtiTGkD4jzXs7igdFmZExPpmwUrRN5195xGup 2 | SOLANA_RPC_URL=https://wrpc.accessprotocol.co 3 | SOLANA_NETWORK="mainnet-beta" 4 | GO_API_URL=https://go-api.accessprotocol.co 5 | UNSTAKE_BASE_URL=https://hub.accessprotocol.co/creators 6 | REWARDS_BASE_URL=https://hub.accessprotocol.co/rewards 7 | GET_ACS_URL=https://hub.accessprotocol.co/get-acs 8 | TOKEN_MINT=5MAYDfq5yxtudAhtfyuMBuHZjgAbaS9tbEyEQYAhDS5y 9 | -------------------------------------------------------------------------------- /.env.staging: -------------------------------------------------------------------------------- 1 | PROGRAM_ID=9LPrKE24UaN9Bsf5rXCS4ZGor9VmjAUxkLCMKHr73sdV 2 | SOLANA_NETWORK=devnet 3 | SOLANA_RPC_URL=https://api.devnet.solana.com 4 | GO_API_URL=https://st-go-api.accessprotocol.co 5 | UNSTAKE_BASE_URL=https://st-app.accessprotocol.co/creators 6 | REWARDS_BASE_URL=https://st-app.accessprotocol.co/rewards 7 | GET_ACS_URL=https://st-app.accessprotocol.co/get-acs 8 | TOKEN_MINT=5hGLVuE4wHW8mcHUJKEyoJYeg653bj8nZeXgUJrfMxFC 9 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | build-ci: 13 | runs-on: ubuntu-latest 14 | 15 | strategy: 16 | matrix: 17 | node-version: [18.x] 18 | 19 | steps: 20 | - uses: actions/checkout@v3 21 | - name: Use Node.js ${{ matrix.node-version }} 22 | uses: actions/setup-node@v3 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | - run: yarn install --immutable --immutable-cache --check-cache 26 | - run: yarn run lint 27 | - run: yarn run build-staging-release 28 | - run: yarn run test 29 | -------------------------------------------------------------------------------- /.github/workflows/release-production.yml: -------------------------------------------------------------------------------- 1 | name: Release to production 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" # Push events to matching v*, i.e. v1.0, v20.15.10 7 | 8 | jobs: 9 | release: 10 | if: ${{ !endsWith(github.ref, '-beta') }} 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: actions/setup-node@v3 16 | with: 17 | node-version: "18.x" 18 | - run: yarn install --immutable --immutable-cache --check-cache 19 | - run: yarn run build-production-release 20 | - run: zip -j widget dist/* README.md 21 | 22 | - name: Create Release 23 | id: create_release 24 | uses: actions/create-release@v1 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | with: 28 | tag_name: ${{ github.ref }} 29 | release_name: Release ${{ github.ref }} 30 | draft: false 31 | prerelease: false 32 | 33 | - name: Upload Release Asset 34 | uses: actions/upload-release-asset@v1 35 | env: 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | with: 38 | upload_url: ${{ steps.create_release.outputs.upload_url }} 39 | asset_path: ./widget.zip 40 | asset_name: widget.zip 41 | asset_content_type: application/zip 42 | 43 | - name: S3 Upload 44 | uses: jakejarvis/s3-sync-action@master 45 | with: 46 | args: --follow-symlinks --delete 47 | env: 48 | SOURCE_DIR: "./dist" 49 | DEST_DIR: "acs-widget" 50 | AWS_REGION: ${{ secrets.AWS_S3_REGION }} 51 | AWS_S3_BUCKET: ${{ secrets.AWS_S3_BUCKET }} 52 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 53 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 54 | 55 | # TODO: Move to federated OpenID concept instead: https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services 56 | - name: Invalidate CDN 57 | uses: chetan/invalidate-cloudfront-action@v2 58 | env: 59 | DISTRIBUTION: ${{ secrets.AWS_CF_DISTRIBUTION }} 60 | # TODO: Update only updated files w example: https://github.com/chetan/invalidate-cloudfront-action 61 | PATHS: "/acs-widget/widget.js /acs-widget/index.html /acs-widget/favicon.ico" 62 | AWS_REGION: ${{ secrets.AWS_S3_REGION }} 63 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 64 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 65 | -------------------------------------------------------------------------------- /.github/workflows/release-staging.yml: -------------------------------------------------------------------------------- 1 | name: Release to staging 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*-beta" 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: actions/setup-node@v3 15 | with: 16 | node-version: "18.x" 17 | - run: yarn install --immutable --immutable-cache --check-cache 18 | - run: yarn run build-staging-release 19 | - run: zip -j widget dist/* README.md 20 | 21 | - name: Create Release 22 | id: create_release 23 | uses: actions/create-release@v1 24 | env: 25 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 26 | with: 27 | tag_name: ${{ github.ref }} 28 | release_name: Release ${{ github.ref }} 29 | draft: false 30 | prerelease: false 31 | 32 | - name: Upload Release Asset 33 | uses: actions/upload-release-asset@v1 34 | env: 35 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 36 | with: 37 | upload_url: ${{ steps.create_release.outputs.upload_url }} 38 | asset_path: ./widget.zip 39 | asset_name: widget.zip 40 | asset_content_type: application/zip 41 | 42 | - name: S3 Upload 43 | uses: jakejarvis/s3-sync-action@master 44 | with: 45 | args: --follow-symlinks --delete 46 | env: 47 | SOURCE_DIR: "./dist" 48 | DEST_DIR: "acs-widget-staging" 49 | AWS_REGION: ${{ secrets.AWS_S3_REGION }} 50 | AWS_S3_BUCKET: ${{ secrets.AWS_S3_BUCKET }} 51 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 52 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 53 | 54 | # TODO: Move to federated OpenID concept instead: https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services 55 | - name: Invalidate CDN 56 | uses: chetan/invalidate-cloudfront-action@v2 57 | env: 58 | DISTRIBUTION: ${{ secrets.AWS_CF_DISTRIBUTION }} 59 | # TODO: Update only updated files w example: https://github.com/chetan/invalidate-cloudfront-action 60 | PATHS: "/acs-widget-staging/*" 61 | AWS_REGION: ${{ secrets.AWS_S3_REGION }} 62 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 63 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 64 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | .env.development* 60 | 61 | dist/ 62 | stats.json 63 | 64 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "esbenp.prettier-vscode", 5 | "mikestead.dotenv" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "label": "Project wide type checking with TypeScript", 8 | "type": "npm", 9 | "script": "build-types", 10 | "problemMatcher": ["$tsc"], 11 | "group": { 12 | "kind": "build", 13 | "isDefault": true 14 | }, 15 | "presentation": { 16 | "clear": true, 17 | "reveal": "never" 18 | } 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.tabSize": 2, 3 | "editor.defaultFormatter": "dbaeumer.vscode-eslint", 4 | "editor.formatOnSave": true, 5 | "editor.codeActionsOnSave": [ 6 | "source.addMissingImports", 7 | "source.fixAll.eslint" 8 | ], 9 | "files.exclude": { 10 | "**/.git": true, 11 | "**/.DS_Store": true, 12 | "**/*.js": { "when": "$(basename).ts" }, 13 | "**/*.js.map": true, 14 | "dist/**": true, 15 | "access-protocol/**": true 16 | }, 17 | "typescript.preferences.quoteStyle": "double", 18 | "typescript.tsdk": "node_modules/typescript/lib", 19 | // Multiple language settings for json and jsonc files 20 | "[json][jsonc]": { 21 | "editor.formatOnSave": true, 22 | "editor.defaultFormatter": "esbenp.prettier-vscode" 23 | }, 24 | "[typescriptreact]": { 25 | "editor.formatOnSave": true, 26 | "editor.defaultFormatter": "esbenp.prettier-vscode" 27 | }, 28 | "[typescript]": { 29 | "editor.defaultFormatter": "esbenp.prettier-vscode" 30 | }, 31 | "editor.fontFamily": "'Martian Mono', Menlo, Monaco, 'Courier New', monospace" 32 | } 33 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 GitHub, Inc. and contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ACS Web Widget 2 | 3 | This is a web widget to help you integrate with ACS protocol. 4 | 5 | ## Usage 6 | 7 | In order to embed the widget add the following snippet at any location on the hosting page: 8 | 9 | ```html 10 | 11 | 12 | 13 | 14 | 15 | ... 16 |
17 | ... 18 | 46 | 47 | ``` 48 | 49 | You can find a full list of configurations in `AppConfigurations` interface. 50 | To make it work for you own pool, make sure you're change the `poolId` and `poolName`. 51 | You can optionally change CSS class prefix `classPrefix` to provide your CSS styling for the app and prevent collision with your names. (By default our classPrefix is set to "acs__" which should be enough to not colide with anyone). 52 | 53 | ## Production builds 54 | 55 | For production use these URLs: 56 | - `https://d3bgshfwq8wmv6.cloudfront.net/acs-widget/widget.js` 57 | - `https://d3bgshfwq8wmv6.cloudfront.net/acs-widget/main.css` 58 | 59 | ## Develop 60 | 61 | The widget dev setup is similar to regular client application. To get started: 62 | 63 | ```bash 64 | yarn install 65 | cp .env.example .env.development 66 | vim .env.development # Fill in the right contract program ID 67 | yarn dev 68 | ``` 69 | 70 | This will open browser with "demo" page which hosts the widget. 71 | 72 | ## Release new version to staging 73 | ```bash 74 | git push origin main 75 | git tag vX.X.X-beta && git push origin vX.X.X-beta 76 | ``` 77 | 78 | After this wait for the Github Actions to finish the deploy to S3 and Cloudfront. 79 | The demo app will be avail at: https://d3bgshfwq8wmv6.cloudfront.net/acs-widget-staging/index.html 80 | 81 | 82 | ## Release new version to production 83 | 84 | ```bash 85 | git push origin main 86 | git tag vX.X.X && git push origin vX.X.X 87 | ``` 88 | 89 | After this wait for the Github Actions to finish the deploy to S3 and Cloudfront. 90 | The demo app will be avail at: https://d3bgshfwq8wmv6.cloudfront.net/acs-widget/index.html 91 | 92 | ## License 93 | The source and documentation in this project are released under the [MIT License](LICENSE) 94 | -------------------------------------------------------------------------------- /html-dev/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Access-Labs-Inc/widget/71700a1a3485f4abdb045f9b2104ed44f6ceaed6/html-dev/favicon.ico -------------------------------------------------------------------------------- /html-dev/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | ACS Widget dev page 7 | 8 | 9 | 10 | 11 | 12 |
13 |

ACS Widget dev page

14 |

The shown widget is for demonstration purpose only

15 | 16 |
17 |
18 | 19 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /html-production/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Access-Labs-Inc/widget/71700a1a3485f4abdb045f9b2104ed44f6ceaed6/html-production/favicon.ico -------------------------------------------------------------------------------- /html-production/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | ACS Widget dev page 7 | 8 | 9 | 10 | 11 | 12 |
13 |

ACS Widget dev page

14 |

The shown widget is for demonstration purpose only

15 | 16 |
17 |
18 | 19 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /html-staging/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Access-Labs-Inc/widget/71700a1a3485f4abdb045f9b2104ed44f6ceaed6/html-staging/favicon.ico -------------------------------------------------------------------------------- /html-staging/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | ACS Widget dev page 7 | 8 | 9 | 10 | 11 | 12 |
13 |

ACS Widget dev page

14 |

The shown widget is for demonstration purpose only

15 | 16 |
17 |
18 | 19 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'jest'; 2 | 3 | const config: Config = { 4 | verbose: true, 5 | testEnvironment: 'jsdom', 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "acs-widget", 3 | "browserslist": [ 4 | ">0.2%", 5 | "not dead", 6 | "not op_mini all" 7 | ], 8 | "scripts": { 9 | "build-production-release": "webpack --config webpack.config.js --env TARGET_ENV=production", 10 | "build-staging-release": "webpack --config webpack.config.js --env TARGET_ENV=staging", 11 | "dev": "NODE_ENV=development webpack serve --env TARGET_ENV=development", 12 | "lint": "tslint --project tsconfig.json ./src/**/*.tsx ./src/**/*.ts ./src/**/*.js", 13 | "lint-fix": "tslint --project tsconfig.json ./src/**/*.tsx ./src/**/*.ts ./src/**/*.js --fix", 14 | "test": "jest", 15 | "stats": "NODE_ENV=production webpack --env TARGET_ENV=staging --profile --json > stats.json", 16 | "knip": "knip" 17 | }, 18 | "license": "MIT", 19 | "devDependencies": { 20 | "@accessprotocol/js": "2.1.1", 21 | "@babel/core": "^7.8.3", 22 | "@babel/plugin-proposal-class-properties": "^7.8.3", 23 | "@babel/plugin-proposal-object-rest-spread": "^7.8.3", 24 | "@babel/plugin-transform-react-jsx": "^7.8.3", 25 | "@babel/plugin-transform-typescript": "^7.8.3", 26 | "@babel/polyfill": "^7.8.3", 27 | "@babel/preset-env": "^7.8.3", 28 | "@babel/preset-typescript": "^7.8.3", 29 | "@babel/runtime": "^7.19.4", 30 | "@statoscope/webpack-plugin": "^5.24.0", 31 | "@tsconfig/recommended": "^1.0.1", 32 | "@types/bn.js": "^5.1.1", 33 | "@types/jest": "^25.2.1", 34 | "@types/react-slider": "^1.3.1", 35 | "autoprefixer": "^10.4.14", 36 | "babel-loader": "^8.0.6", 37 | "browserify-zlib": "^0.2.0", 38 | "bs58": "^5.0.0", 39 | "buffer": "^6.0.3", 40 | "compression-webpack-plugin": "^11.1.0", 41 | "copy-webpack-plugin": "^5.1.1", 42 | "crypto-browserify": "^3.12.0", 43 | "css-loader": "^3.4.2", 44 | "dotenv": "^16.0.3", 45 | "dotenv-webpack": "^8.0.1", 46 | "glob": "^9.3.2", 47 | "inspectpack": "^4.7.1", 48 | "jest": "^29.0.0", 49 | "jest-environment-jsdom": "^29.2.1", 50 | "knip": "^0.9.0", 51 | "mini-css-extract-plugin": "^2.7.5", 52 | "path-browserify": "^1.0.1", 53 | "phosphor-react": "^1.4.1", 54 | "postcss": "^8.4.18", 55 | "postcss-loader": "^7.1.0", 56 | "purgecss-webpack-plugin": "^5.0.0", 57 | "stream-browserify": "^3.0.0", 58 | "style-loader": "^1.1.3", 59 | "svg-url-loader": "^8.0.0", 60 | "tailwindcss": "^3.3.1", 61 | "ts-node": "^10.9.1", 62 | "tslint": "^5.20.1", 63 | "typescript": "^4.6.2", 64 | "webpack": "^5", 65 | "webpack-cli": "^4.10.0", 66 | "webpack-dev-server": "^4.11.1" 67 | }, 68 | "dependencies": { 69 | "@solana/spl-token": "^0.3.5", 70 | "@solana/wallet-adapter-base": "^0.9.22", 71 | "@solana/wallet-adapter-react": "^0.15.32", 72 | "@solana/wallet-adapter-wallets": "^0.19.16", 73 | "@solana/web3.js": "^1.66.1", 74 | "@supercharge/promise-pool": "^3.2.0", 75 | "bn.js": "^5.2.1", 76 | "borsh": "^0.7.0", 77 | "clsx": "^1.2.1", 78 | "core-js": "2", 79 | "preact": "^10.2.1", 80 | "rc-util": "^5.24.4", 81 | "react-input-slider": "^6.0.1", 82 | "zod": "^3.23.8" 83 | }, 84 | "resolutions": { 85 | "buffer": "6.0.3", 86 | "bn.js": "5.2.1", 87 | "readable-stream": "3.6.0", 88 | "string_decoder": "1.3.0", 89 | "@project-serum/sol-wallet-adapter": "0.2.6", 90 | "preact": "10.11.1", 91 | "detect-browser": "5.3.0", 92 | "@stablelib/random": "1.0.2", 93 | "@ledgerhq/hw-transport": "6.27.6", 94 | "@emotion/memoize": "0.8.0", 95 | "@solana/web3.js": "1.66.1", 96 | "loader-utils": "2.0.4", 97 | "glob-parent": "^5.1.2" 98 | }, 99 | "knip": { 100 | "entryFiles": [ 101 | "src/index.ts" 102 | ], 103 | "projectFiles": [ 104 | "src/**/*.ts", 105 | "!**/*.spec.ts" 106 | ], 107 | "dev": { 108 | "entryFiles": [ 109 | "src/index.ts", 110 | "src/**/*.spec.ts", 111 | "src/**/*.e2e.ts" 112 | ], 113 | "projectFiles": [ 114 | "src/**/*.ts" 115 | ] 116 | } 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { h } from "preact"; 2 | import { useMemo } from "preact/hooks"; 3 | import { WalletAdapterNetwork } from "@solana/wallet-adapter-base"; 4 | import { 5 | PhantomWalletAdapter, 6 | SolflareWalletAdapter, 7 | } from "@solana/wallet-adapter-wallets"; 8 | 9 | import { ConnectionProvider } from "./components/wallet-adapter/ConnectionProvider"; 10 | import { WalletProvider } from "./components/wallet-adapter/WalletProvider"; 11 | import { WalletModalProvider } from "./components/wallet-adapter/ui/WalletModalProvider"; 12 | 13 | import { Configurations } from "./models"; 14 | import Main from "./layout/Main"; 15 | import { AppContext } from "./AppContext"; 16 | import env from "./libs/env"; 17 | 18 | type Props = Configurations; 19 | export const App = ({ element, ...appSettings }: Props) => { 20 | const network = env.SOLANA_NETWORK as WalletAdapterNetwork; 21 | console.log("Connected to network: ", network); 22 | 23 | const endpoint = env.SOLANA_RPC_URL; 24 | 25 | const wallets = useMemo( 26 | () => [new PhantomWalletAdapter(), new SolflareWalletAdapter({ network })], 27 | [network] 28 | ); 29 | return ( 30 | 31 | 32 | 33 | 34 |
35 | 36 | 37 | 38 | 39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /src/AppContext.tsx: -------------------------------------------------------------------------------- 1 | import { h, createContext, ComponentChildren } from "preact"; 2 | import { Configurations } from "./models"; 3 | 4 | export const ConfigContext = createContext( 5 | {} as Configurations 6 | ); 7 | 8 | interface Props { 9 | children: ComponentChildren; 10 | config: Configurations; 11 | element?: HTMLElement; 12 | } 13 | export const AppContext = ({ children, config, element }: Props) => { 14 | const enhancedConfig = { ...config, element }; 15 | return ( 16 | 17 | {children} 18 | 19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import { h, ComponentChildren } from 'preact'; 2 | import { useState, useMemo, useCallback, useContext } from 'preact/hooks'; 3 | import { Copy, ArrowUpRight } from 'phosphor-react'; 4 | import { ConfigContext } from '../AppContext'; 5 | 6 | import { useWallet } from './wallet-adapter/useWallet'; 7 | import { clsxp } from '../libs/utils'; 8 | 9 | export const Header = ({ children }: { children: ComponentChildren }) => { 10 | const { classPrefix } = useContext(ConfigContext); 11 | const [copied, setCopied] = useState(false); 12 | const { publicKey } = useWallet(); 13 | 14 | const base58 = useMemo(() => publicKey?.toBase58(), [publicKey]); 15 | const shortBase58 = useMemo(() => { 16 | if (!base58) { 17 | return null; 18 | } 19 | return `${base58.slice(0, 4)}..${base58.slice(-4)}`; 20 | }, [base58]); 21 | const copyAddress = useCallback(async () => { 22 | if (base58) { 23 | await navigator.clipboard.writeText(base58); 24 | setCopied(true); 25 | setTimeout(() => setCopied(false), 400); 26 | } 27 | }, [base58]); 28 | 29 | return ( 30 |
31 |
35 | {copied ? ( 36 |
37 | Copied! 38 |
39 | ) : ( 40 |
41 |
42 |
43 | {shortBase58} 44 |
45 | 46 |
47 | 53 | 54 | 55 |
56 | )} 57 |
58 | {children} 59 |
60 | ); 61 | }; 62 | -------------------------------------------------------------------------------- /src/components/Loading.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { useContext } from 'preact/hooks'; 3 | import { ConfigContext } from '../AppContext'; 4 | import { clsxp } from '../libs/utils'; 5 | 6 | const Loading = () => { 7 | const { classPrefix } = useContext(ConfigContext); 8 | 9 | return ( 10 | 16 | 24 | 29 | 30 | ); 31 | }; 32 | 33 | export default Loading; 34 | -------------------------------------------------------------------------------- /src/components/NumberInputWithSlider.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionalComponent, h } from 'preact'; 2 | import { useContext, useRef } from 'preact/hooks'; 3 | import RcInputNumber from '../libs/rc-input-number'; 4 | import RcSlider from 'react-input-slider'; 5 | import { formatACSCurrency } from '../libs/utils'; 6 | import { ConfigContext } from '../AppContext'; 7 | import { clsxp } from '../libs/utils'; 8 | 9 | export interface InputProps { 10 | invalid?: boolean; 11 | invalidText?: string | null; 12 | onChangeOfValue: (value: number) => void; 13 | value: number; 14 | disabled: boolean; 15 | min: number; 16 | max: number; 17 | } 18 | 19 | function setNativeValue( 20 | element: HTMLInputElement, 21 | value: string | number | undefined 22 | ) { 23 | if (element) { 24 | const valueSetter = Object.getOwnPropertyDescriptor(element, 'value')?.set; 25 | const prototype = Object.getPrototypeOf(element); 26 | const prototypeValueSetter = Object.getOwnPropertyDescriptor( 27 | prototype, 28 | 'value' 29 | )?.set; 30 | 31 | if (valueSetter && valueSetter !== prototypeValueSetter) { 32 | prototypeValueSetter?.call(element, value); 33 | } else { 34 | valueSetter?.call(element, value); 35 | } 36 | } 37 | } 38 | 39 | export const NumberInputWithSlider: FunctionalComponent = ( 40 | props 41 | ) => { 42 | const { classPrefix } = useContext(ConfigContext); 43 | const { min, max, onChangeOfValue, value } = props; 44 | const inputRef = useRef(null); 45 | 46 | const changeToMin = () => { 47 | if (inputRef.current) { 48 | setNativeValue(inputRef.current, min); 49 | inputRef.current.dispatchEvent(new Event('input', { bubbles: true })); 50 | } 51 | if (onChangeOfValue) { 52 | onChangeOfValue(Number(min)); 53 | } 54 | }; 55 | 56 | const changeToMax = () => { 57 | if (inputRef.current) { 58 | setNativeValue(inputRef.current, max); 59 | inputRef.current.dispatchEvent(new Event('input', { bubbles: true })); 60 | } 61 | if (onChangeOfValue) { 62 | onChangeOfValue(Number(max)); 63 | } 64 | }; 65 | 66 | const handleSliderChange = (values: { x: number; y: number }) => { 67 | if (onChangeOfValue) { 68 | onChangeOfValue(Number(values.x)); 69 | } 70 | }; 71 | 72 | const handleChange = (newValue: number) => { 73 | if (onChangeOfValue) { 74 | onChangeOfValue(Number(newValue)); 75 | } 76 | }; 77 | 78 | return ( 79 |
80 | formatACSCurrency(newValue)} 88 | onChange={handleChange} 89 | /> 90 |
91 | 113 |
114 |
115 | {value === max && max && min && max > min ? ( 116 | Min 117 | ) : null} 118 | {value !== max && max && min && min < max ? ( 119 | Max 120 | ) : null} 121 |
122 |
123 | ); 124 | }; 125 | -------------------------------------------------------------------------------- /src/components/ProgressModal.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment, h } from 'preact'; 2 | import { RouteLink } from '../layout/Router'; 3 | import { useContext, useEffect, useState } from 'preact/hooks'; 4 | import { ConfigContext } from '../AppContext'; 5 | import { clsxp } from '../libs/utils'; 6 | import Loading from './Loading'; 7 | 8 | const ProgressModal = ({ 9 | working, 10 | doneStepName, 11 | }: { 12 | working: string; 13 | doneStepName: string; 14 | }) => { 15 | const { classPrefix } = useContext(ConfigContext); 16 | const [countdown, setCountdown] = useState(5); 17 | 18 | useEffect(() => { 19 | if (working === doneStepName && countdown > 0) { 20 | const timer = setTimeout(() => setCountdown(countdown - 1), 1000); 21 | return () => clearTimeout(timer); 22 | } 23 | }, [working, doneStepName, countdown]); 24 | 25 | const isButtonDisabled = working !== doneStepName || countdown > 0; 26 | 27 | return ( 28 | 29 |
30 | Sign a transaction 31 |
32 |
33 | {working === doneStepName 34 | ? 'Transaction sent successfully.' 35 | : 'We need you to sign a transaction to lock your funds.'} 36 |
37 | 58 |
59 | ); 60 | }; 61 | 62 | export { ProgressModal }; 63 | -------------------------------------------------------------------------------- /src/components/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentChildren, h } from 'preact'; 2 | import { useContext } from 'preact/hooks'; 3 | import { ConfigContext } from '../AppContext'; 4 | import { clsxp } from '../libs/utils'; 5 | 6 | export const Tooltip = ({ 7 | messages, 8 | children, 9 | }: { 10 | messages: string[]; 11 | children: ComponentChildren; 12 | }) => { 13 | const { classPrefix } = useContext(ConfigContext); 14 | 15 | return ( 16 |
17 | {children} 18 |
19 |
20 |
21 | {messages 22 | .filter((message) => message != null && message !== '') 23 | .map((message, i) => ( 24 |
{message}
27 | ))} 28 |
29 |
30 |
31 |
32 |
33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /src/components/wallet-adapter/ConnectionProvider.tsx: -------------------------------------------------------------------------------- 1 | import { type ConnectionConfig, Connection } from '@solana/web3.js'; 2 | import { ComponentChildren, h } from 'preact'; 3 | import { useMemo } from 'preact/hooks'; 4 | import { ConnectionContext } from './useConnection'; 5 | 6 | export interface ConnectionProviderProps { 7 | children: ComponentChildren; 8 | endpoint: string; 9 | config?: ConnectionConfig; 10 | } 11 | 12 | export const ConnectionProvider = ({ 13 | children, 14 | endpoint, 15 | config = { commitment: 'confirmed' }, 16 | }: ConnectionProviderProps) => { 17 | const connection = useMemo( 18 | () => new Connection(endpoint, config), 19 | [endpoint, config] 20 | ); 21 | 22 | return ( 23 | 24 | {children} 25 | 26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /src/components/wallet-adapter/WalletProvider.tsx: -------------------------------------------------------------------------------- 1 | import type { 2 | Adapter, 3 | MessageSignerWalletAdapterProps, 4 | SignerWalletAdapterProps, 5 | WalletAdapterProps, 6 | WalletError, 7 | WalletName, 8 | } from "@solana/wallet-adapter-base"; 9 | import { 10 | WalletNotConnectedError, 11 | WalletNotReadyError, 12 | WalletReadyState, 13 | } from "@solana/wallet-adapter-base"; 14 | import type { PublicKey } from "@solana/web3.js"; 15 | import { h, ComponentChildren } from "preact"; 16 | import { 17 | useCallback, 18 | useEffect, 19 | useMemo, 20 | useRef, 21 | useState, 22 | } from "preact/hooks"; 23 | 24 | import { WalletNotSelectedError } from "./errors"; 25 | import { useLocalStorage } from "./useLocalStorage"; 26 | import { type Wallet, WalletContext } from "./useWallet"; 27 | 28 | export interface WalletProviderProps { 29 | children: ComponentChildren; 30 | wallets: Adapter[]; 31 | autoConnect?: boolean; 32 | onError?: (error: WalletError) => void; 33 | localStorageKey?: string; 34 | } 35 | 36 | const initialState: { 37 | wallet: Wallet | null; 38 | adapter: Adapter | null; 39 | publicKey: PublicKey | null; 40 | connected: boolean; 41 | } = { 42 | wallet: null, 43 | adapter: null, 44 | publicKey: null, 45 | connected: false, 46 | }; 47 | 48 | export const WalletProvider = ({ 49 | children, 50 | wallets: adapters, 51 | autoConnect = false, 52 | onError, 53 | localStorageKey = "walletName", 54 | }: WalletProviderProps) => { 55 | const [name, setName] = useLocalStorage( 56 | localStorageKey, 57 | null 58 | ); 59 | const [{ wallet, adapter, publicKey, connected }, setState] = 60 | useState(initialState); 61 | const readyState = adapter?.readyState || WalletReadyState.Unsupported; 62 | const [connecting, setConnecting] = useState(false); 63 | const [disconnecting, setDisconnecting] = useState(false); 64 | const isConnecting = useRef(false); 65 | const isDisconnecting = useRef(false); 66 | const isUnloading = useRef(false); 67 | 68 | // Wrap adapters to conform to the `Wallet` interface 69 | const [wallets, setWallets] = useState(() => 70 | adapters.map((adapter) => ({ 71 | adapter, 72 | readyState: adapter.readyState, 73 | })) 74 | ); 75 | 76 | // When the adapters change, start to listen for changes to their `readyState` 77 | useEffect(() => { 78 | // When the adapters change, wrap them to conform to the `Wallet` interface 79 | setWallets((wallets) => 80 | adapters.map((adapter, index) => { 81 | const wallet = wallets[index]; 82 | // If the wallet hasn't changed, return the same instance 83 | return wallet && 84 | wallet.adapter === adapter && 85 | wallet.readyState === adapter.readyState 86 | ? wallet 87 | : { 88 | adapter: adapter, 89 | readyState: adapter.readyState, 90 | }; 91 | }) 92 | ); 93 | 94 | function handleReadyStateChange( 95 | this: Adapter, 96 | readyState: WalletReadyState 97 | ) { 98 | setWallets((prevWallets) => { 99 | const index = prevWallets.findIndex(({ adapter }) => adapter === this); 100 | if (index === -1) return prevWallets; 101 | 102 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 103 | const { adapter } = prevWallets[index]; 104 | return [ 105 | ...prevWallets.slice(0, index), 106 | { adapter, readyState }, 107 | ...prevWallets.slice(index + 1), 108 | ]; 109 | }); 110 | } 111 | 112 | adapters.forEach((adapter) => 113 | adapter.on("readyStateChange", handleReadyStateChange, adapter) 114 | ); 115 | return () => 116 | adapters.forEach((adapter) => 117 | adapter.off("readyStateChange", handleReadyStateChange, adapter) 118 | ); 119 | }, [adapters]); 120 | 121 | // When the selected wallet changes, initialize the state 122 | useEffect(() => { 123 | const wallet = name && wallets.find(({ adapter }) => adapter.name === name); 124 | if (wallet) { 125 | setState({ 126 | wallet, 127 | adapter: wallet.adapter, 128 | connected: wallet.adapter.connected, 129 | publicKey: wallet.adapter.publicKey, 130 | }); 131 | } else { 132 | setState(initialState); 133 | } 134 | }, [name, wallets]); 135 | 136 | // If the window is closing or reloading, ignore disconnect and error events from the adapter 137 | useEffect(() => { 138 | function listener() { 139 | isUnloading.current = true; 140 | } 141 | 142 | window.addEventListener("beforeunload", listener); 143 | return () => window.removeEventListener("beforeunload", listener); 144 | }, [isUnloading]); 145 | 146 | // Handle the adapter's connect event 147 | const handleConnect = useCallback(() => { 148 | if (!adapter) return; 149 | setState((state) => ({ 150 | ...state, 151 | connected: adapter.connected, 152 | publicKey: adapter.publicKey, 153 | })); 154 | }, [adapter]); 155 | 156 | // Handle the adapter's disconnect event 157 | const handleDisconnect = useCallback(() => { 158 | // Clear the selected wallet unless the window is unloading 159 | if (!isUnloading.current) setName(null); 160 | }, [isUnloading, setName]); 161 | 162 | // Handle the adapter's error event, and local errors 163 | const handleError = useCallback( 164 | (error: WalletError) => { 165 | // Call onError unless the window is unloading 166 | if (!isUnloading.current) (onError || console.error)(error); 167 | return error; 168 | }, 169 | [isUnloading, onError] 170 | ); 171 | 172 | // Setup and teardown event listeners when the adapter changes 173 | useEffect(() => { 174 | if (adapter) { 175 | adapter.on("connect", handleConnect); 176 | adapter.on("disconnect", handleDisconnect); 177 | adapter.on("error", handleError); 178 | return () => { 179 | adapter.off("connect", handleConnect); 180 | adapter.off("disconnect", handleDisconnect); 181 | adapter.off("error", handleError); 182 | }; 183 | } 184 | return () => {}; 185 | }, [adapter, handleConnect, handleDisconnect, handleError]); 186 | 187 | // When the adapter changes, disconnect the old one 188 | useEffect(() => { 189 | return () => { 190 | adapter?.disconnect(); 191 | }; 192 | }, [adapter]); 193 | 194 | // If autoConnect is enabled, try to connect when the adapter changes and is ready 195 | useEffect(() => { 196 | if ( 197 | isConnecting.current || 198 | connected || 199 | !autoConnect || 200 | !adapter || 201 | !( 202 | readyState === WalletReadyState.Installed || 203 | readyState === WalletReadyState.Loadable 204 | ) 205 | ) 206 | return; 207 | 208 | (async function () { 209 | isConnecting.current = true; 210 | setConnecting(true); 211 | try { 212 | await adapter.connect(); 213 | } catch (error) { 214 | // Clear the selected wallet 215 | setName(null); 216 | // Don't throw error, but handleError will still be called 217 | } finally { 218 | setConnecting(false); 219 | isConnecting.current = false; 220 | } 221 | })(); 222 | }, [isConnecting, connected, autoConnect, adapter, readyState, setName]); 223 | 224 | // Connect the adapter to the wallet 225 | const connect = useCallback(async () => { 226 | if (isConnecting.current || isDisconnecting.current || connected) return; 227 | if (!adapter) throw handleError(new WalletNotSelectedError()); 228 | 229 | if ( 230 | !( 231 | readyState === WalletReadyState.Installed || 232 | readyState === WalletReadyState.Loadable 233 | ) 234 | ) { 235 | // Clear the selected wallet 236 | setName(null); 237 | 238 | if (typeof window !== "undefined") { 239 | window.open(adapter.url, "_blank"); 240 | } 241 | 242 | throw handleError(new WalletNotReadyError()); 243 | } 244 | 245 | isConnecting.current = true; 246 | setConnecting(true); 247 | try { 248 | await adapter.connect(); 249 | } catch (error) { 250 | // Clear the selected wallet 251 | setName(null); 252 | // Rethrow the error, and handleError will also be called 253 | throw error; 254 | } finally { 255 | setConnecting(false); 256 | isConnecting.current = false; 257 | } 258 | }, [ 259 | isConnecting, 260 | isDisconnecting, 261 | connected, 262 | adapter, 263 | readyState, 264 | handleError, 265 | setName, 266 | ]); 267 | 268 | // Disconnect the adapter from the wallet 269 | const disconnect = useCallback(async () => { 270 | if (isDisconnecting.current) return; 271 | if (!adapter) return setName(null); 272 | 273 | isDisconnecting.current = true; 274 | setDisconnecting(true); 275 | try { 276 | await adapter.disconnect(); 277 | } catch (error) { 278 | // Clear the selected wallet 279 | setName(null); 280 | // Rethrow the error, and handleError will also be called 281 | throw error; 282 | } finally { 283 | setDisconnecting(false); 284 | isDisconnecting.current = false; 285 | } 286 | }, [isDisconnecting, adapter, setName]); 287 | 288 | // Send a transaction using the provided connection 289 | const sendTransaction: WalletAdapterProps["sendTransaction"] = useCallback( 290 | async (transaction, connection, options) => { 291 | if (!adapter) throw handleError(new WalletNotSelectedError()); 292 | if (!connected) throw handleError(new WalletNotConnectedError()); 293 | return await adapter.sendTransaction(transaction, connection, options); 294 | }, 295 | [adapter, handleError, connected] 296 | ); 297 | 298 | // Sign a transaction if the wallet supports it 299 | const signTransaction: 300 | | SignerWalletAdapterProps["signTransaction"] 301 | | undefined = useMemo( 302 | () => 303 | adapter && "signTransaction" in adapter 304 | ? async (transaction) => { 305 | if (!connected) throw handleError(new WalletNotConnectedError()); 306 | return await adapter.signTransaction(transaction); 307 | } 308 | : undefined, 309 | [adapter, handleError, connected] 310 | ); 311 | 312 | // Sign multiple transactions if the wallet supports it 313 | const signAllTransactions: 314 | | SignerWalletAdapterProps["signAllTransactions"] 315 | | undefined = useMemo( 316 | () => 317 | adapter && "signAllTransactions" in adapter 318 | ? async (transactions) => { 319 | if (!connected) throw handleError(new WalletNotConnectedError()); 320 | return await adapter.signAllTransactions(transactions); 321 | } 322 | : undefined, 323 | [adapter, handleError, connected] 324 | ); 325 | 326 | // Sign an arbitrary message if the wallet supports it 327 | const signMessage: 328 | | MessageSignerWalletAdapterProps["signMessage"] 329 | | undefined = useMemo( 330 | () => 331 | adapter && "signMessage" in adapter 332 | ? async (message) => { 333 | if (!connected) throw handleError(new WalletNotConnectedError()); 334 | return await adapter.signMessage(message); 335 | } 336 | : undefined, 337 | [adapter, handleError, connected] 338 | ); 339 | 340 | return ( 341 | 359 | {children} 360 | 361 | ); 362 | }; 363 | -------------------------------------------------------------------------------- /src/components/wallet-adapter/errors.ts: -------------------------------------------------------------------------------- 1 | import { WalletError } from '@solana/wallet-adapter-base'; 2 | 3 | export class WalletNotSelectedError extends WalletError { 4 | name = 'WalletNotSelectedError'; 5 | } 6 | -------------------------------------------------------------------------------- /src/components/wallet-adapter/ui/Button.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentChildren, FunctionalComponent, h } from "preact"; 2 | import { CSSProperties, useContext } from "preact/compat"; 3 | import { ConfigContext } from "../../../AppContext"; 4 | import { clsxp } from "../../../libs/utils"; 5 | 6 | export type ButtonProps = { 7 | id?: string; 8 | className?: string; 9 | disabled?: boolean; 10 | endIcon?: ComponentChildren; 11 | onClick?: (e: MouseEvent) => void; 12 | startIcon?: ComponentChildren; 13 | externalButtonClass?: string | null; 14 | style?: CSSProperties; 15 | tabIndex?: number; 16 | }; 17 | 18 | export const Button: FunctionalComponent = (props) => { 19 | const { classPrefix } = useContext(ConfigContext); 20 | return ( 21 | 41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /src/components/wallet-adapter/ui/Collapse.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionalComponent, type ComponentChildren, h } from "preact"; 2 | import { useContext, useLayoutEffect, useRef } from "preact/hooks"; 3 | import { ConfigContext } from "../../../AppContext"; 4 | import { clsxp } from "../../../libs/utils"; 5 | 6 | export type CollapseProps = { 7 | children: ComponentChildren; 8 | expanded: boolean; 9 | id: string; 10 | }; 11 | 12 | export const Collapse: FunctionalComponent = ({ 13 | id, 14 | children, 15 | expanded = false, 16 | }) => { 17 | const { classPrefix } = useContext(ConfigContext); 18 | const ref = useRef(null); 19 | const instant = useRef(true); 20 | const transition = "height 250ms ease-out"; 21 | 22 | const openCollapse = () => { 23 | const node = ref.current; 24 | if (!node) return; 25 | 26 | requestAnimationFrame(() => { 27 | node.style.height = `${node.scrollHeight}px`; 28 | }); 29 | }; 30 | 31 | const closeCollapse = () => { 32 | const node = ref.current; 33 | if (!node) return; 34 | 35 | requestAnimationFrame(() => { 36 | node.style.height = `${node.offsetHeight}px`; 37 | node.style.overflow = "hidden"; 38 | requestAnimationFrame(() => { 39 | node.style.height = "0"; 40 | }); 41 | }); 42 | }; 43 | 44 | useLayoutEffect(() => { 45 | if (expanded) { 46 | openCollapse(); 47 | } else { 48 | closeCollapse(); 49 | } 50 | }, [expanded]); 51 | 52 | useLayoutEffect(() => { 53 | const node = ref.current; 54 | if (!node) return; 55 | 56 | function handleComplete() { 57 | if (!node) return; 58 | 59 | node.style.overflow = expanded ? "initial" : "hidden"; 60 | if (expanded) { 61 | node.style.height = "auto"; 62 | } 63 | } 64 | 65 | function handleTransitionEnd(event: TransitionEvent) { 66 | if (node && event.target === node && event.propertyName === "height") { 67 | handleComplete(); 68 | } 69 | } 70 | 71 | if (instant.current) { 72 | handleComplete(); 73 | instant.current = false; 74 | } 75 | 76 | node.addEventListener("transitionend", handleTransitionEnd); 77 | return () => node.removeEventListener("transitionend", handleTransitionEnd); 78 | }, [expanded]); 79 | 80 | return ( 81 |
92 | {children} 93 |
94 | ); 95 | }; 96 | -------------------------------------------------------------------------------- /src/components/wallet-adapter/ui/WalletConnectButton.tsx: -------------------------------------------------------------------------------- 1 | import { useWallet } from "../useWallet"; 2 | import { type FunctionalComponent, h } from "preact"; 3 | import { useCallback, useContext, useMemo } from "preact/hooks"; 4 | import type { ButtonProps } from "./Button"; 5 | import { Button } from "./Button"; 6 | import { ConfigContext } from "../../../AppContext"; 7 | import { clsxp } from "../../../libs/utils"; 8 | 9 | export const WalletConnectButton: FunctionalComponent = ({ 10 | children, 11 | disabled, 12 | onClick, 13 | ...props 14 | }) => { 15 | const { classPrefix } = useContext(ConfigContext); 16 | const { wallet, connect, connecting, connected } = useWallet(); 17 | 18 | const handleClick = useCallback( 19 | (event: MouseEvent) => { 20 | if (onClick) onClick(event); 21 | // eslint-disable-next-line @typescript-eslint/no-empty-function 22 | if (!event.defaultPrevented) connect().catch(() => {}); 23 | }, 24 | [onClick, connect] 25 | ); 26 | 27 | const content = useMemo(() => { 28 | if (children) return children; 29 | if (connecting) return "Connecting ..."; 30 | if (connected) return "Connected"; 31 | if (wallet) return "Connect"; 32 | return "Connect Wallet"; 33 | }, [children, connecting, connected, wallet]); 34 | 35 | return ( 36 | 44 | ); 45 | }; 46 | -------------------------------------------------------------------------------- /src/components/wallet-adapter/ui/WalletIcon.tsx: -------------------------------------------------------------------------------- 1 | import { h } from "preact"; 2 | import type { Wallet } from "@solana/wallet-adapter-react"; 3 | import type { FunctionalComponent } from "preact"; 4 | import { useContext } from "preact/hooks"; 5 | import { ConfigContext } from "../../../AppContext"; 6 | import { clsxp } from "../../../libs/utils"; 7 | 8 | export interface WalletIconProps { 9 | wallet: Wallet | null; 10 | } 11 | 12 | export const WalletIcon: FunctionalComponent = ({ 13 | wallet, 14 | ...props 15 | }) => { 16 | const { classPrefix } = useContext(ConfigContext); 17 | return ( 18 | wallet && ( 19 | {`${wallet.adapter.name} 25 | ) 26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /src/components/wallet-adapter/ui/WalletListItem.tsx: -------------------------------------------------------------------------------- 1 | import { h } from "preact"; 2 | import type { Wallet } from "../useWallet"; 3 | import type { FunctionalComponent } from "preact"; 4 | 5 | import { Button } from "./Button"; 6 | import { WalletIcon } from "./WalletIcon"; 7 | import { useContext } from "preact/hooks"; 8 | import { ConfigContext } from "../../../AppContext"; 9 | import { clsxp } from "../../../libs/utils"; 10 | 11 | export interface WalletListItemProps { 12 | handleClick: (e: MouseEvent) => void; 13 | tabIndex?: number; 14 | wallet: Wallet; 15 | } 16 | 17 | export const WalletListItem: FunctionalComponent = ({ 18 | handleClick, 19 | tabIndex, 20 | wallet, 21 | }) => { 22 | const { classPrefix } = useContext(ConfigContext); 23 | return ( 24 |
  • 25 | 33 |
  • 34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /src/components/wallet-adapter/ui/WalletModal.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionalComponent, h } from "preact"; 2 | import { 3 | useCallback, 4 | useContext, 5 | useEffect, 6 | useLayoutEffect, 7 | useMemo, 8 | useRef, 9 | useState, 10 | } from "preact/hooks"; 11 | import { createPortal, Fragment } from "preact/compat"; 12 | 13 | import type { WalletName } from "@solana/wallet-adapter-base"; 14 | import { WalletReadyState } from "@solana/wallet-adapter-base"; 15 | import { useWallet, type Wallet } from "../useWallet"; 16 | 17 | import { Collapse } from "./Collapse"; 18 | import { useWalletModal } from "./useWalletModal"; 19 | import { WalletListItem } from "./WalletListItem"; 20 | import { ConfigContext } from "../../../AppContext"; 21 | import { clsxp } from "../../../libs/utils"; 22 | 23 | export interface WalletModalProps { 24 | className?: string; 25 | container?: string; 26 | } 27 | 28 | export const WalletModal: FunctionalComponent = ({ 29 | className = "", 30 | container = "#wallet-modal-button", 31 | }) => { 32 | const { classPrefix } = useContext(ConfigContext); 33 | const ref = useRef(null); 34 | const { wallets, select } = useWallet(); 35 | const { setVisible } = useWalletModal(); 36 | const [expanded, setExpanded] = useState(false); 37 | const [portal, setPortal] = useState(null); 38 | 39 | const [installedWallets, otherWallets] = useMemo(() => { 40 | const installed: Wallet[] = []; 41 | const notDetected: Wallet[] = []; 42 | const loadable: Wallet[] = []; 43 | 44 | for (const wallet of wallets) { 45 | if (wallet.readyState === WalletReadyState.NotDetected) { 46 | notDetected.push(wallet); 47 | } else if (wallet.readyState === WalletReadyState.Loadable) { 48 | loadable.push(wallet); 49 | } else if (wallet.readyState === WalletReadyState.Installed) { 50 | installed.push(wallet); 51 | } 52 | } 53 | 54 | return [installed, [...loadable, ...notDetected]]; 55 | }, [wallets]); 56 | 57 | const getStartedWallet = useMemo(() => { 58 | return installedWallets.length 59 | ? installedWallets[0] 60 | : wallets.find( 61 | (wallet: { adapter: { name: WalletName } }) => 62 | wallet.adapter.name === "Torus" 63 | ) || 64 | wallets.find( 65 | (wallet: { adapter: { name: WalletName } }) => 66 | wallet.adapter.name === "Phantom" 67 | ) || 68 | wallets.find( 69 | (wallet: { readyState: WalletReadyState }) => 70 | wallet.readyState === WalletReadyState.Loadable 71 | ) || 72 | otherWallets[0]; 73 | }, [installedWallets, wallets, otherWallets]); 74 | 75 | const hideModal = useCallback(() => { 76 | setTimeout(() => setVisible(false), 150); 77 | }, [setVisible]); 78 | 79 | const handleClose = useCallback( 80 | (event: MouseEvent) => { 81 | event.preventDefault(); 82 | hideModal(); 83 | }, 84 | [hideModal] 85 | ); 86 | 87 | const handleWalletClick = useCallback( 88 | (event: MouseEvent, walletName: WalletName) => { 89 | select(walletName); 90 | handleClose(event); 91 | }, 92 | [select, handleClose] 93 | ); 94 | 95 | const handleCollapseClick = useCallback( 96 | () => setExpanded(!expanded), 97 | [expanded] 98 | ); 99 | 100 | const handleTabKey = useCallback( 101 | (event: KeyboardEvent) => { 102 | const node = ref.current; 103 | if (!node) return; 104 | 105 | // here we query all focusable elements 106 | const focusableElements = node.querySelectorAll("button"); 107 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 108 | const firstElement = focusableElements[0]; 109 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 110 | const lastElement = focusableElements[focusableElements.length - 1]; 111 | 112 | if (event.shiftKey) { 113 | // if going backward by pressing tab and firstElement is active, shift focus to last focusable element 114 | if (document.activeElement === firstElement) { 115 | lastElement.focus(); 116 | event.preventDefault(); 117 | } 118 | } else { 119 | // if going forward by pressing tab and lastElement is active, shift focus to first focusable element 120 | if (document.activeElement === lastElement) { 121 | firstElement.focus(); 122 | event.preventDefault(); 123 | } 124 | } 125 | }, 126 | [ref] 127 | ); 128 | 129 | useEffect(() => { 130 | const listener = (event: MouseEvent | TouchEvent) => { 131 | const node = ref.current; 132 | 133 | // Do nothing if clicking dropdown or its descendants 134 | if (!node || node.contains(event.target as Node)) return; 135 | 136 | hideModal(); 137 | }; 138 | 139 | document.addEventListener("mousedown", listener); 140 | document.addEventListener("touchstart", listener); 141 | 142 | return () => { 143 | document.removeEventListener("mousedown", listener); 144 | document.removeEventListener("touchstart", listener); 145 | }; 146 | }, [ref, hideModal]); 147 | 148 | useLayoutEffect(() => { 149 | const handleKeyDown = (event: KeyboardEvent) => { 150 | if (event.key === "Escape") { 151 | hideModal(); 152 | } else if (event.key === "Tab") { 153 | handleTabKey(event); 154 | } 155 | }; 156 | 157 | // Get original overflow 158 | const { overflow } = window.getComputedStyle(document.body); 159 | // Prevent scrolling on mount 160 | document.body.style.overflow = "hidden"; 161 | // Listen for keydown events 162 | window.addEventListener("keydown", handleKeyDown, false); 163 | 164 | return () => { 165 | // Re-enable scrolling when component unmounts 166 | document.body.style.overflow = overflow; 167 | window.removeEventListener("keydown", handleKeyDown, false); 168 | }; 169 | }, [hideModal, handleTabKey]); 170 | 171 | useLayoutEffect(() => { 172 | const containerEl = document.querySelector(container); 173 | const portalEl = document.createElement("div"); 174 | if (containerEl?.parentNode && portalEl) 175 | containerEl.parentNode.insertBefore( 176 | portalEl, 177 | containerEl.nextElementSibling 178 | ); 179 | setPortal(portalEl); 180 | }, [container]); 181 | 182 | return ( 183 | portal && 184 | createPortal( 185 |
    192 |
    193 |
    194 | {installedWallets.length ? ( 195 | 196 |

    199 | Connect your wallet 200 |

    201 |

    207 | You need a Solana wallet to 208 |
    connect to the website. 209 |

    210 |
      211 | {installedWallets.map((wallet) => ( 212 | 215 | handleWalletClick(event, wallet.adapter.name) 216 | } 217 | wallet={wallet} 218 | /> 219 | ))} 220 | {otherWallets.length ? ( 221 | 225 | {otherWallets.map((wallet) => ( 226 | 229 | handleWalletClick(event, wallet.adapter.name) 230 | } 231 | tabIndex={expanded ? 0 : -1} 232 | wallet={wallet} 233 | /> 234 | ))} 235 | 236 | ) : null} 237 |
    238 | {otherWallets.length ? ( 239 | 269 | ) : null} 270 |
    271 | ) : ( 272 | 273 |

    276 | You'll need a wallet 277 |

    278 |
    281 | 293 |
    294 | {otherWallets.length ? ( 295 | 296 | 329 | 333 |
      339 | {otherWallets.map((wallet) => ( 340 | 343 | handleWalletClick(event, wallet.adapter.name) 344 | } 345 | tabIndex={expanded ? 0 : -1} 346 | wallet={wallet} 347 | /> 348 | ))} 349 |
    350 |
    351 |
    352 | ) : null} 353 |
    354 | )} 355 |
    356 |
    357 |
    , 358 | portal 359 | ) 360 | ); 361 | }; 362 | -------------------------------------------------------------------------------- /src/components/wallet-adapter/ui/WalletModalButton.tsx: -------------------------------------------------------------------------------- 1 | import { type FunctionalComponent, h } from "preact"; 2 | import { useCallback, useContext } from "preact/hooks"; 3 | import { ConfigContext } from "../../../AppContext"; 4 | import { clsxp } from "../../../libs/utils"; 5 | import type { ButtonProps } from "./Button"; 6 | import { Button } from "./Button"; 7 | import { useWalletModal } from "./useWalletModal"; 8 | 9 | export const WalletModalButton: FunctionalComponent = ({ 10 | children = "Select Wallet", 11 | onClick, 12 | ...props 13 | }) => { 14 | const { classPrefix } = useContext(ConfigContext); 15 | const { visible, setVisible } = useWalletModal(); 16 | 17 | const handleClick = useCallback( 18 | (event: MouseEvent) => { 19 | if (onClick) onClick(event); 20 | if (!event.defaultPrevented) setVisible(!visible); 21 | }, 22 | [onClick, setVisible, visible] 23 | ); 24 | 25 | return ( 26 | 34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /src/components/wallet-adapter/ui/WalletModalProvider.tsx: -------------------------------------------------------------------------------- 1 | import { h, ComponentChildren, FunctionalComponent } from 'preact'; 2 | import { useState } from 'preact/hooks'; 3 | import { WalletModalContext } from './useWalletModal'; 4 | import type { WalletModalProps } from './WalletModal'; 5 | import { WalletModal } from './WalletModal'; 6 | 7 | export interface WalletModalProviderProps extends WalletModalProps { 8 | children: ComponentChildren; 9 | } 10 | 11 | export const WalletModalProvider: FunctionalComponent< 12 | WalletModalProviderProps 13 | > = ({ children, ...props }) => { 14 | const [visible, setVisible] = useState(false); 15 | 16 | return ( 17 | 23 | {children} 24 | {visible && } 25 | 26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /src/components/wallet-adapter/ui/useWalletModal.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'preact'; 2 | import { useContext } from 'preact/hooks'; 3 | 4 | export interface WalletModalContextState { 5 | visible: boolean; 6 | setVisible: (open: boolean) => void; 7 | } 8 | 9 | const DEFAULT_CONTEXT = { 10 | setVisible(_open: boolean) { 11 | console.error(constructMissingProviderErrorMessage('call', 'setVisible')); 12 | }, 13 | visible: false, 14 | }; 15 | Object.defineProperty(DEFAULT_CONTEXT, 'visible', { 16 | get() { 17 | console.error(constructMissingProviderErrorMessage('read', 'visible')); 18 | return false; 19 | }, 20 | }); 21 | 22 | function constructMissingProviderErrorMessage( 23 | action: string, 24 | valueName: string 25 | ) { 26 | return ( 27 | 'You have tried to ' + 28 | ` ${action} "${valueName}"` + 29 | ' on a WalletModalContext without providing one.' + 30 | ' Make sure to render a WalletModalProvider' + 31 | ' as an ancestor of the component that uses ' + 32 | 'WalletModalContext' 33 | ); 34 | } 35 | 36 | export const WalletModalContext = createContext( 37 | DEFAULT_CONTEXT as WalletModalContextState 38 | ); 39 | 40 | export function useWalletModal(): WalletModalContextState { 41 | return useContext(WalletModalContext); 42 | } 43 | -------------------------------------------------------------------------------- /src/components/wallet-adapter/useConnection.ts: -------------------------------------------------------------------------------- 1 | import type { Connection } from '@solana/web3.js'; 2 | import { createContext } from 'preact'; 3 | import { useContext } from 'preact/hooks'; 4 | 5 | export interface ConnectionContextState { 6 | connection: Connection; 7 | } 8 | 9 | export const ConnectionContext = createContext({} as ConnectionContextState); 10 | 11 | export function useConnection(): ConnectionContextState { 12 | return useContext(ConnectionContext); 13 | } 14 | -------------------------------------------------------------------------------- /src/components/wallet-adapter/useLocalStorage.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "preact/hooks"; 2 | 3 | export function useLocalStorage(key: string, defaultState: T): [T, any] { 4 | const state = useState(() => { 5 | try { 6 | const value = localStorage.getItem(key); 7 | if (value) return JSON.parse(value) as T; 8 | } catch (error) { 9 | if (typeof window !== "undefined") { 10 | console.error(error); 11 | } 12 | } 13 | 14 | return defaultState; 15 | }); 16 | const value = state[0]; 17 | 18 | const isFirstRender = useRef(true); 19 | useEffect(() => { 20 | if (isFirstRender.current) { 21 | isFirstRender.current = false; 22 | return; 23 | } 24 | try { 25 | if (value === null) { 26 | localStorage.removeItem(key); 27 | } else { 28 | localStorage.setItem(key, JSON.stringify(value)); 29 | } 30 | } catch (error) { 31 | if (typeof window !== "undefined") { 32 | console.error(error); 33 | } 34 | } 35 | }, [value, key]); 36 | 37 | return state; 38 | } 39 | -------------------------------------------------------------------------------- /src/components/wallet-adapter/useWallet.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Adapter, 3 | MessageSignerWalletAdapterProps, 4 | SendTransactionOptions, 5 | SignerWalletAdapterProps, 6 | WalletAdapterProps, 7 | WalletName, 8 | WalletReadyState, 9 | } from '@solana/wallet-adapter-base'; 10 | import type { Connection, PublicKey, Transaction, VersionedTransaction } from '@solana/web3.js'; 11 | import { createContext } from 'preact'; 12 | import { useContext } from 'preact/hooks'; 13 | 14 | export interface Wallet { 15 | adapter: Adapter; 16 | readyState: WalletReadyState; 17 | } 18 | 19 | export interface WalletContextState { 20 | autoConnect: boolean; 21 | wallets: Wallet[]; 22 | wallet: Wallet | null; 23 | publicKey: PublicKey | null; 24 | connecting: boolean; 25 | connected: boolean; 26 | disconnecting: boolean; 27 | 28 | select(walletName: WalletName): void; 29 | connect(): Promise; 30 | disconnect(): Promise; 31 | 32 | sendTransaction: WalletAdapterProps['sendTransaction']; 33 | signTransaction: SignerWalletAdapterProps['signTransaction'] | undefined; 34 | signAllTransactions: SignerWalletAdapterProps['signAllTransactions'] | undefined; 35 | signMessage: MessageSignerWalletAdapterProps['signMessage'] | undefined; 36 | } 37 | 38 | const EMPTY_ARRAY: ReadonlyArray = []; 39 | 40 | const DEFAULT_CONTEXT = { 41 | autoConnect: false, 42 | connecting: false, 43 | connected: false, 44 | disconnecting: false, 45 | select(_name: WalletName) { 46 | console.error(constructMissingProviderErrorMessage('get', 'select')); 47 | }, 48 | connect() { 49 | return Promise.reject(console.error(constructMissingProviderErrorMessage('get', 'connect'))); 50 | }, 51 | disconnect() { 52 | return Promise.reject(console.error(constructMissingProviderErrorMessage('get', 'disconnect'))); 53 | }, 54 | sendTransaction( 55 | _transaction: VersionedTransaction | Transaction, 56 | _connection: Connection, 57 | _options?: SendTransactionOptions 58 | ) { 59 | return Promise.reject(console.error(constructMissingProviderErrorMessage('get', 'sendTransaction'))); 60 | }, 61 | signTransaction(_transaction: Transaction) { 62 | return Promise.reject(console.error(constructMissingProviderErrorMessage('get', 'signTransaction'))); 63 | }, 64 | signAllTransactions(_transaction: Transaction[]) { 65 | return Promise.reject(console.error(constructMissingProviderErrorMessage('get', 'signAllTransactions'))); 66 | }, 67 | signMessage(_message: Uint8Array) { 68 | return Promise.reject(console.error(constructMissingProviderErrorMessage('get', 'signMessage'))); 69 | }, 70 | } as WalletContextState; 71 | Object.defineProperty(DEFAULT_CONTEXT, 'wallets', { 72 | get() { 73 | console.error(constructMissingProviderErrorMessage('read', 'wallets')); 74 | return EMPTY_ARRAY; 75 | }, 76 | }); 77 | Object.defineProperty(DEFAULT_CONTEXT, 'wallet', { 78 | get() { 79 | console.error(constructMissingProviderErrorMessage('read', 'wallet')); 80 | return null; 81 | }, 82 | }); 83 | Object.defineProperty(DEFAULT_CONTEXT, 'publicKey', { 84 | get() { 85 | console.error(constructMissingProviderErrorMessage('read', 'publicKey')); 86 | return null; 87 | }, 88 | }); 89 | 90 | function constructMissingProviderErrorMessage(action: string, valueName: string) { 91 | return ( 92 | 'You have tried to ' + 93 | ` ${action} "${valueName}"` + 94 | ' on a WalletContext without providing one.' + 95 | ' Make sure to render a WalletProvider' + 96 | ' as an ancestor of the component that uses ' + 97 | 'WalletContext' 98 | ); 99 | } 100 | 101 | export const WalletContext = createContext(DEFAULT_CONTEXT as WalletContextState); 102 | 103 | export function useWallet(): WalletContextState { 104 | return useContext(WalletContext); 105 | } 106 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { h, render } from "preact"; 2 | import { App } from "./App"; 3 | import loader from "./loader"; 4 | import { Configurations } from "./models"; 5 | 6 | import "./main.css"; 7 | 8 | /** 9 | * Default configurations that are overridden by 10 | * parameters in embedded script. 11 | */ 12 | const defaultConfig: Configurations = { 13 | poolId: null, 14 | poolName: null, 15 | debug: true, 16 | classPrefix: "acs__", 17 | }; 18 | 19 | // main entry point - calls loader and render Preact app into supplied element 20 | loader(window, defaultConfig, window.document.currentScript, (el, config) => 21 | render(h(App, { ...config, element: el }), el) 22 | ); 23 | -------------------------------------------------------------------------------- /src/layout/Main.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { 3 | useCallback, 4 | useContext, 5 | useEffect, 6 | useMemo, 7 | useRef, 8 | useState, 9 | } from 'preact/hooks'; 10 | import { Router, RouteComponent } from '../layout/Router'; 11 | import { Actions } from '../routes/Actions'; 12 | import { Stake } from '../routes/Stake'; 13 | import { Unstake } from '../routes/Unstake'; 14 | import { Button } from '../components/wallet-adapter/ui/Button'; 15 | import { WalletConnectButton } from '../components/wallet-adapter/ui/WalletConnectButton'; 16 | import { WalletModalButton } from '../components/wallet-adapter/ui/WalletModalButton'; 17 | import { useWallet } from '../components/wallet-adapter/useWallet'; 18 | import { ConfigContext } from '../AppContext'; 19 | import env from '../libs/env'; 20 | import { clsxp } from '../libs/utils'; 21 | import { offchainBasicSubscriptionsSchema } from '../validations/subscriptions'; 22 | 23 | const Main = () => { 24 | const { publicKey, wallet, connected } = useWallet(); 25 | const [active, setActive] = useState(false); 26 | const ref = useRef(null); 27 | const { element, poolId, classPrefix } = useContext(ConfigContext); 28 | const base58 = useMemo(() => publicKey?.toBase58(), [publicKey]); 29 | const content = useMemo(() => { 30 | if (!wallet || !base58) { 31 | return null; 32 | } 33 | return `${base58.slice(0, 4)}..${base58.slice(-4)}`; 34 | }, [wallet, base58]); 35 | 36 | const toggleDropdown = useCallback(() => { 37 | setActive(!active); 38 | }, [active]); 39 | 40 | const closeDropdown = useCallback(() => { 41 | setActive(false); 42 | }, []); 43 | 44 | useEffect(() => { 45 | if (connected && element && publicKey && poolId) { 46 | (async () => { 47 | const response = await fetch( 48 | `${env.GO_API_URL}/subscriptions/${publicKey.toBase58()}` 49 | ); 50 | if (!response.ok) { 51 | console.log('ERROR: ', response.statusText); 52 | return; 53 | } 54 | 55 | const json = await response.json(); 56 | const data = offchainBasicSubscriptionsSchema.parse(json); 57 | 58 | const { staked, bonds, forever } = data.reduce( 59 | (acc, item) => { 60 | if (item.pool === poolId) { 61 | return { 62 | staked: acc.staked + (item?.locked ?? 0), 63 | bonds: acc.bonds + (item?.bonds ?? 0), 64 | forever: acc.forever + (item?.forever ?? 0), 65 | }; 66 | } else { 67 | return acc; 68 | } 69 | }, 70 | { 71 | staked: 0, 72 | bonds: 0, 73 | forever: 0, 74 | } 75 | ); 76 | 77 | const connectedEvent = new CustomEvent('connected', { 78 | detail: { 79 | address: base58, 80 | locked: staked + bonds + forever, 81 | staked, 82 | bonds, 83 | forever, 84 | }, 85 | bubbles: true, 86 | cancelable: true, 87 | composed: false, // if you want to listen on parent turn this on 88 | }); 89 | console.log('Connected event: ', connectedEvent); 90 | element.dispatchEvent(connectedEvent); 91 | })(); 92 | } 93 | }, [connected, element]); 94 | 95 | useEffect(() => { 96 | const listener = (event: MouseEvent | TouchEvent) => { 97 | const node = ref.current; 98 | 99 | // Do nothing if clicking dropdown or its descendants 100 | if (!node || node.contains(event.target as Node)) { 101 | return; 102 | } 103 | 104 | closeDropdown(); 105 | }; 106 | 107 | document.addEventListener('mousedown', listener); 108 | document.addEventListener('touchstart', listener); 109 | 110 | return () => { 111 | document.removeEventListener('mousedown', listener); 112 | document.removeEventListener('touchstart', listener); 113 | }; 114 | }, [ref, closeDropdown]); 115 | 116 | if (!wallet) { 117 | return ( 118 |
    119 | 120 |
    121 | ); 122 | } 123 | if (!base58) { 124 | return ( 125 |
    126 | 127 |
    128 | ); 129 | } 130 | 131 | return ( 132 |
    133 | 145 |
    152 | , 155 | '/stake': , 156 | '/unstake': , 157 | }} 158 | /> 159 |
    160 |
    161 | ); 162 | }; 163 | 164 | export default Main; 165 | -------------------------------------------------------------------------------- /src/layout/Router.tsx: -------------------------------------------------------------------------------- 1 | import { h, createContext, VNode, ComponentType, createElement } from 'preact'; 2 | import { useState, useEffect } from 'preact/hooks'; 3 | 4 | const DEFAULT_ROUTE = '/'; 5 | 6 | interface Props { 7 | /** 8 | * Specifies all URLs and their respectful components. 9 | */ 10 | routes: { 11 | [DEFAULT_ROUTE]: VNode; 12 | [key: string]: VNode; 13 | }; 14 | onChange?: (route: string) => void; 15 | } 16 | 17 | /** 18 | * Stores current URL of the router and allows to change it programmatically. 19 | */ 20 | export const RouterContext = createContext<{ 21 | route: string; 22 | setRoute: (route: string) => void; 23 | }>({ route: DEFAULT_ROUTE, setRoute: (_: string) => undefined }); 24 | 25 | /** 26 | * Oversimplified router component. 27 | */ 28 | export const Router = ({ routes, onChange }: Props) => { 29 | const [route, setRoute] = useState(DEFAULT_ROUTE); 30 | useEffect(() => onChange?.(route), [route]); 31 | 32 | return ( 33 | 34 | {routes[route]} 35 | 36 | ); 37 | }; 38 | 39 | export const RouteComponent = (props: { component: ComponentType }) => 40 | createElement(props.component, null); 41 | 42 | /** 43 | * Render anchor with click handler to switch route based on `href` attribute. 44 | * We intentionally override final `href`, so links within widget won't lead to actual 45 | * pages on website. 46 | */ 47 | export const RouteLink = ({ href, children, disabled, ...rest }: any) => ( 48 | 49 | {({ setRoute }) => ( 50 | href && !disabled && setRoute(href as string)} 53 | {...rest} 54 | > 55 | {children} 56 | 57 | )} 58 | 59 | ); 60 | -------------------------------------------------------------------------------- /src/libs/env.ts: -------------------------------------------------------------------------------- 1 | import { PublicKey } from '@solana/web3.js'; 2 | 3 | // Must be written like this othwerwise the webpack will not be able to replace the values!! 4 | const PROGRAM_ID = process.env.PROGRAM_ID; 5 | const TOKEN_MINT = process.env.TOKEN_MINT; 6 | const SOLANA_RPC_URL = process.env.SOLANA_RPC_URL; 7 | const SOLANA_NETWORK = process.env.SOLANA_NETWORK; 8 | const GO_API_URL = process.env.GO_API_URL; 9 | const UNSTAKE_BASE_URL = process.env.UNSTAKE_BASE_URL; 10 | const REWARDS_BASE_URL = process.env.REWARDS_BASE_URL; 11 | const GET_ACS_URL = process.env.GET_ACS_URL; 12 | 13 | if (!SOLANA_RPC_URL) { 14 | throw new Error('SOLANA_RPC_URL must be set!'); 15 | } 16 | 17 | if (!SOLANA_NETWORK) { 18 | throw new Error('SOLANA_NETWORK must be set!'); 19 | } 20 | 21 | if (!PROGRAM_ID) { 22 | throw new Error('PROGRAM_ID must be set!'); 23 | } 24 | 25 | if (!TOKEN_MINT) { 26 | throw new Error('TOKEN_MINT must be set!'); 27 | } 28 | 29 | if (!UNSTAKE_BASE_URL) { 30 | throw new Error('UNSTAKE_BASE_URL must be set!'); 31 | } 32 | 33 | if (!REWARDS_BASE_URL) { 34 | throw new Error('REWARDS_BASE_URL must be set!'); 35 | } 36 | 37 | if (!GET_ACS_URL) { 38 | throw new Error('GET_ACS_URL must be set!'); 39 | } 40 | 41 | if (!GO_API_URL) { 42 | throw new Error('GO_API_URL must be set!'); 43 | } 44 | 45 | interface Config { 46 | SOLANA_RPC_URL: string; 47 | SOLANA_NETWORK: string; 48 | PROGRAM_ID: PublicKey; 49 | TOKEN_MINT: PublicKey; 50 | GO_API_URL: string; 51 | UNSTAKE_BASE_URL: string; 52 | REWARDS_BASE_URL: string; 53 | GET_ACS_URL: string; 54 | } 55 | 56 | const config: Config = { 57 | SOLANA_RPC_URL, 58 | SOLANA_NETWORK, 59 | PROGRAM_ID: new PublicKey(PROGRAM_ID), 60 | TOKEN_MINT: new PublicKey(TOKEN_MINT), 61 | GO_API_URL, 62 | UNSTAKE_BASE_URL, 63 | REWARDS_BASE_URL, 64 | GET_ACS_URL, 65 | }; 66 | 67 | export default config; 68 | -------------------------------------------------------------------------------- /src/libs/program.ts: -------------------------------------------------------------------------------- 1 | import { CentralStateV2, StakePool } from '@accessprotocol/js'; 2 | import { 3 | ASSOCIATED_TOKEN_PROGRAM_ID, 4 | getAssociatedTokenAddress, 5 | TOKEN_PROGRAM_ID, 6 | } from '@solana/spl-token'; 7 | import { 8 | Connection, 9 | PublicKey, 10 | MemcmpFilter, 11 | AccountInfo, 12 | RpcResponseAndContext, 13 | TokenAmount, 14 | } from '@solana/web3.js'; 15 | import BN from 'bn.js'; 16 | 17 | /** 18 | * This function can be used to find all stake accounts of a user 19 | * @param connection The Solana RPC connection 20 | * @param owner The owner of the stake accounts to retrieve 21 | * @param programId The program ID 22 | * @returns 23 | */ 24 | export const getStakeAccounts = async ( 25 | connection: Connection, 26 | owner: PublicKey, 27 | programId: PublicKey 28 | ) => { 29 | const filters: MemcmpFilter[] = [ 30 | { 31 | memcmp: { 32 | offset: 0, 33 | bytes: '4', 34 | }, 35 | }, 36 | { 37 | memcmp: { 38 | offset: 1, 39 | bytes: owner.toBase58(), 40 | }, 41 | }, 42 | ]; 43 | return connection.getProgramAccounts(programId, { 44 | filters, 45 | }); 46 | }; 47 | 48 | /** 49 | * This function can be used to find all bonds of a user 50 | * @param connection The Solana RPC connection 51 | * @param owner The owner of the bonds to retrieve 52 | * @param programId The program ID 53 | * @returns 54 | */ 55 | export const getBondAccounts = async ( 56 | connection: Connection, 57 | owner: PublicKey, 58 | programId: PublicKey 59 | ) => { 60 | const filters = [ 61 | { 62 | memcmp: { 63 | offset: 0, 64 | bytes: '6', 65 | }, 66 | }, 67 | { 68 | memcmp: { 69 | offset: 1, 70 | bytes: owner.toBase58(), 71 | }, 72 | }, 73 | ]; 74 | return await connection.getProgramAccounts(programId, { 75 | filters, 76 | }); 77 | }; 78 | 79 | const calculateReward = ( 80 | unclaimedDays: number, 81 | stakePool: StakePool, 82 | staker: boolean, 83 | ): BN => { 84 | if (unclaimedDays <= 0) { 85 | return new BN(0); 86 | } 87 | const BUFF_LEN = 274; 88 | const nbDaysBehind = 89 | unclaimedDays > BUFF_LEN - 1 ? BUFF_LEN - 1 : unclaimedDays; 90 | 91 | const idx = stakePool.currentDayIdx; 92 | let i = (idx - nbDaysBehind) % BUFF_LEN; 93 | 94 | let reward = new BN(0); 95 | while (i !== idx % BUFF_LEN) { 96 | const rewardForDday = staker 97 | ? ((stakePool.balances[i]?.stakersReward ?? new BN(0)) as BN) 98 | : stakePool.balances[i]?.poolReward ?? (new BN(0) as BN); 99 | reward = reward.add(rewardForDday); 100 | i = (i + 1) % BUFF_LEN; 101 | } 102 | return reward; 103 | }; 104 | 105 | export const calculateRewardForStaker = ( 106 | unclaimedDays: number, 107 | stakePool: StakePool, 108 | stakeAmount: BN 109 | ) => { 110 | const reward = calculateReward(unclaimedDays, stakePool, true); 111 | return reward.mul(stakeAmount).iushrn(31).addn(1).iushrn(1).toNumber(); 112 | }; 113 | 114 | export const getUserACSBalance = async ( 115 | connection: Connection, 116 | publicKey: PublicKey, 117 | programId: PublicKey 118 | ): Promise => { 119 | const [centralKey] = CentralStateV2.getKey(programId); 120 | const centralState = await CentralStateV2.retrieve(connection, centralKey); 121 | const userAta: PublicKey = await getAssociatedTokenAddress( 122 | centralState.tokenMint, 123 | publicKey, 124 | true, 125 | TOKEN_PROGRAM_ID, 126 | ASSOCIATED_TOKEN_PROGRAM_ID 127 | ); 128 | const userAccount: AccountInfo | null = 129 | await connection.getAccountInfo(userAta); 130 | if (userAccount) { 131 | const accTokensBalance: RpcResponseAndContext = 132 | await connection.getTokenAccountBalance(userAta); 133 | return new BN(accTokensBalance.value.amount); 134 | } 135 | return new BN(0); 136 | }; 137 | -------------------------------------------------------------------------------- /src/libs/rc-input-number/InputNumber.tsx: -------------------------------------------------------------------------------- 1 | import { h, Ref } from "preact"; 2 | import { 3 | useCallback, 4 | useContext, 5 | useMemo, 6 | useRef, 7 | useState, 8 | } from "preact/hooks"; 9 | import { ChangeEventHandler, forwardRef } from "preact/compat"; 10 | import KeyCode from "rc-util/lib/KeyCode"; 11 | import { useLayoutUpdateEffect } from "rc-util/lib/hooks/useLayoutEffect"; 12 | import { composeRef } from "rc-util/lib/ref"; 13 | import getMiniDecimal, { 14 | DecimalClass, 15 | toFixed, 16 | ValueType, 17 | } from "./utils/MiniDecimal"; 18 | import { 19 | getNumberPrecision, 20 | num2str, 21 | getDecupleSteps, 22 | validateNumber, 23 | } from "./utils/numberUtil"; 24 | import useCursor from "./hooks/useCursor"; 25 | import useFrame from "./hooks/useFrame"; 26 | import { ConfigContext } from "../../AppContext"; 27 | import { clsxp } from "../utils"; 28 | 29 | /** 30 | * We support `stringMode` which need handle correct type when user call in onChange 31 | * format max or min value 32 | * 1. if isInvalid return null 33 | * 2. if precision is undefined, return decimal 34 | * 3. format with precision 35 | * I. if max > 0, round down with precision. Example: max= 3.5, precision=0 afterFormat: 3 36 | * II. if max < 0, round up with precision. Example: max= -3.5, precision=0 afterFormat: -4 37 | * III. if min > 0, round up with precision. Example: min= 3.5, precision=0 afterFormat: 4 38 | * IV. if min < 0, round down with precision. Example: max= -3.5, precision=0 afterFormat: -3 39 | */ 40 | 41 | const getDecimalValue = (stringMode: boolean, decimalValue: DecimalClass) => { 42 | if (stringMode || decimalValue.isEmpty()) { 43 | return decimalValue.toString(); 44 | } 45 | 46 | return decimalValue.toNumber(); 47 | }; 48 | 49 | const getDecimalIfValidate = (value: ValueType) => { 50 | const decimal = getMiniDecimal(value); 51 | return decimal.isInvalidate() ? null : decimal; 52 | }; 53 | 54 | const InputNumber = forwardRef((props: any, ref: Ref) => { 55 | const { 56 | style, 57 | min, 58 | max, 59 | step = 1, 60 | defaultValue, 61 | value, 62 | disabled, 63 | readOnly, 64 | upHandler, 65 | downHandler, 66 | keyboard, 67 | controls = true, 68 | 69 | stringMode, 70 | 71 | parser, 72 | formatter, 73 | precision, 74 | decimalSeparator, 75 | 76 | onChange, 77 | onInput, 78 | onPressEnter, 79 | onStep, 80 | 81 | ...inputProps 82 | } = props; 83 | 84 | const inputRef = useRef(null); 85 | 86 | const [focus, setFocus] = useState(false); 87 | 88 | const userTypingRef = useRef(false); 89 | const compositionRef = useRef(false); 90 | const shiftKeyRef = useRef(false); 91 | 92 | // ============================ Value ============================= 93 | // Real value control 94 | const [decimalValue, setDecimalValue] = useState(() => 95 | getMiniDecimal(value ?? defaultValue) 96 | ); 97 | 98 | function setUncontrolledDecimalValue(newDecimal: DecimalClass) { 99 | if (value === undefined) { 100 | setDecimalValue(newDecimal); 101 | } 102 | } 103 | 104 | // ====================== Parser & Formatter ====================== 105 | /** 106 | * `precision` is used for formatter & onChange. 107 | * It will auto generate by `value` & `step`. 108 | * But it will not block user typing. 109 | * 110 | * Note: Auto generate `precision` is used for legacy logic. 111 | * We should remove this since we already support high precision with BigInt. 112 | * 113 | * @param number Provide which number should calculate precision 114 | * @param userTyping Change by user typing 115 | */ 116 | const getPrecision = useCallback( 117 | (numStr: string, userTyping: boolean) => { 118 | if (userTyping) { 119 | return undefined; 120 | } 121 | 122 | if (precision && precision >= 0) { 123 | return precision; 124 | } 125 | 126 | return Math.max(getNumberPrecision(numStr), getNumberPrecision(step)); 127 | }, 128 | [precision, step] 129 | ); 130 | 131 | // >>> Parser 132 | const mergedParser = useCallback( 133 | (num: string | number) => { 134 | const numStr = String(num); 135 | 136 | if (parser) { 137 | return parser(numStr); 138 | } 139 | 140 | let parsedStr = numStr; 141 | if (decimalSeparator) { 142 | parsedStr = parsedStr.replace(decimalSeparator, "."); 143 | } 144 | 145 | // [Legacy] We still support auto convert `$ 123,456` to `123456` 146 | return parsedStr.replace(/[^\w.-]+/g, ""); 147 | }, 148 | [parser, decimalSeparator] 149 | ); 150 | 151 | // >>> Formatter 152 | const inputValueRef = useRef(""); 153 | const mergedFormatter = useCallback( 154 | (number: string, userTyping: boolean) => { 155 | if (formatter) { 156 | return formatter(number, { 157 | userTyping, 158 | input: String(inputValueRef.current), 159 | }); 160 | } 161 | 162 | let str = typeof number === "number" ? num2str(number) : number; 163 | 164 | // User typing will not auto format with precision directly 165 | if (!userTyping) { 166 | const mergedPrecision = getPrecision(str, userTyping); 167 | 168 | if (validateNumber(str) && (decimalSeparator || mergedPrecision >= 0)) { 169 | // Separator 170 | const separatorStr = decimalSeparator || "."; 171 | 172 | str = toFixed(str, separatorStr, mergedPrecision); 173 | } 174 | } 175 | 176 | return str; 177 | }, 178 | [formatter, getPrecision, decimalSeparator] 179 | ); 180 | 181 | // ========================== InputValue ========================== 182 | /** 183 | * Input text value control 184 | * 185 | * User can not update input content directly. It update with follow rules by priority: 186 | * 1. controlled `value` changed 187 | * * [SPECIAL] Typing like `1.` should not immediately convert to `1` 188 | * 2. User typing with format (not precision) 189 | * 3. Blur or Enter trigger revalidate 190 | */ 191 | const [inputValue, setInternalInputValue] = useState(() => { 192 | const initValue = defaultValue ?? value; 193 | if ( 194 | decimalValue.isInvalidate() && 195 | ["string", "number"].includes(typeof initValue) 196 | ) { 197 | return Number.isNaN(initValue) ? "" : initValue; 198 | } 199 | return mergedFormatter(decimalValue.toString(), false); 200 | }); 201 | inputValueRef.current = inputValue; 202 | 203 | // Should always be string 204 | function setInputValue(newValue: DecimalClass, userTyping: boolean) { 205 | setInternalInputValue( 206 | mergedFormatter( 207 | // Invalidate number is sometime passed by external control, we should let it go 208 | // Otherwise is controlled by internal interactive logic which check by userTyping 209 | // You can ref 'show limited value when input is not focused' test for more info. 210 | newValue.isInvalidate() 211 | ? newValue.toString(false) 212 | : newValue.toString(!userTyping), 213 | userTyping 214 | ) 215 | ); 216 | } 217 | 218 | // >>> Max & Min limit 219 | const maxDecimal = useMemo(() => getDecimalIfValidate(max), [max, precision]); 220 | const minDecimal = useMemo(() => getDecimalIfValidate(min), [min, precision]); 221 | 222 | const upDisabled = useMemo(() => { 223 | if (!maxDecimal || !decimalValue || decimalValue.isInvalidate()) { 224 | return false; 225 | } 226 | 227 | return maxDecimal.lessEquals(decimalValue); 228 | }, [maxDecimal, decimalValue]); 229 | 230 | const downDisabled = useMemo(() => { 231 | if (!minDecimal || !decimalValue || decimalValue.isInvalidate()) { 232 | return false; 233 | } 234 | 235 | return decimalValue.lessEquals(minDecimal); 236 | }, [minDecimal, decimalValue]); 237 | 238 | // Cursor controller 239 | const [recordCursor, restoreCursor] = useCursor(inputRef.current, focus); 240 | 241 | // ============================= Data ============================= 242 | /** 243 | * Find target value closet within range. 244 | * e.g. [11, 28]: 245 | * 3 => 11 246 | * 23 => 23 247 | * 99 => 28 248 | */ 249 | const getRangeValue = (target: DecimalClass) => { 250 | // target > max 251 | if (maxDecimal && !target.lessEquals(maxDecimal)) { 252 | return maxDecimal; 253 | } 254 | 255 | // target < min 256 | if (minDecimal && !minDecimal.lessEquals(target)) { 257 | return minDecimal; 258 | } 259 | 260 | return null; 261 | }; 262 | 263 | /** 264 | * Check value is in [min, max] range 265 | */ 266 | const isInRange = (target: DecimalClass) => !getRangeValue(target); 267 | 268 | /** 269 | * Trigger `onChange` if value validated and not equals of origin. 270 | * Return the value that re-align in range. 271 | */ 272 | const triggerValueUpdate = ( 273 | newValue: DecimalClass, 274 | userTyping: boolean 275 | ): DecimalClass => { 276 | let updateValue = newValue; 277 | 278 | let isRangeValidate = isInRange(updateValue) || updateValue.isEmpty(); 279 | 280 | // Skip align value when trigger value is empty. 281 | // We just trigger onChange(null) 282 | // This should not block user typing 283 | if (!updateValue.isEmpty() && !userTyping) { 284 | // Revert value in range if needed 285 | updateValue = getRangeValue(updateValue) || updateValue; 286 | isRangeValidate = true; 287 | } 288 | 289 | if (!readOnly && !disabled && isRangeValidate) { 290 | const numStr = updateValue.toString(); 291 | const mergedPrecision = getPrecision(numStr, userTyping); 292 | if (mergedPrecision >= 0) { 293 | updateValue = getMiniDecimal(toFixed(numStr, ".", mergedPrecision)); 294 | 295 | // When to fixed. The value may out of min & max range. 296 | // 4 in [0, 3.8] => 3.8 => 4 (toFixed) 297 | if (!isInRange(updateValue)) { 298 | updateValue = getMiniDecimal( 299 | toFixed(numStr, ".", mergedPrecision, true) 300 | ); 301 | } 302 | } 303 | 304 | // Trigger event 305 | if (!updateValue.equals(decimalValue)) { 306 | setUncontrolledDecimalValue(updateValue); 307 | onChange?.( 308 | updateValue.isEmpty() 309 | ? null 310 | : getDecimalValue(stringMode, updateValue) 311 | ); 312 | 313 | // Reformat input if value is not controlled 314 | if (value === undefined) { 315 | setInputValue(updateValue, userTyping); 316 | } 317 | } 318 | 319 | return updateValue; 320 | } 321 | 322 | return decimalValue; 323 | }; 324 | 325 | // ========================== User Input ========================== 326 | const onNextPromise = useFrame(); 327 | 328 | // >>> Collect input value 329 | const collectInputValue = (inputStr: string) => { 330 | recordCursor(); 331 | 332 | // Update inputValue incase input can not parse as number 333 | setInternalInputValue(inputStr); 334 | 335 | // Parse number 336 | if (!compositionRef.current) { 337 | const finalValue = mergedParser(inputStr); 338 | const finalDecimal = getMiniDecimal(finalValue); 339 | if (!finalDecimal.isNaN()) { 340 | triggerValueUpdate(finalDecimal, true); 341 | } 342 | } 343 | 344 | // Trigger onInput later to let user customize value if they want do handle something after onChange 345 | onInput?.(inputStr); 346 | 347 | // optimize for chinese input experience 348 | // https://github.com/ant-design/ant-design/issues/8196 349 | onNextPromise(() => { 350 | let nextInputStr = inputStr; 351 | if (!parser) { 352 | nextInputStr = inputStr.replace(/。/g, "."); 353 | } 354 | 355 | if (nextInputStr !== inputStr) { 356 | collectInputValue(nextInputStr); 357 | } 358 | }); 359 | }; 360 | 361 | // >>> Composition 362 | const onCompositionStart = () => { 363 | compositionRef.current = true; 364 | }; 365 | 366 | const onCompositionEnd = () => { 367 | compositionRef.current = false; 368 | 369 | if (inputRef.current) { 370 | collectInputValue(inputRef.current.value); 371 | } 372 | }; 373 | 374 | // >>> Input 375 | const onInternalInput: ChangeEventHandler = (e) => { 376 | if ((e.target as HTMLInputElement).value) { 377 | collectInputValue((e.target as HTMLInputElement).value); 378 | } 379 | }; 380 | 381 | // ============================= Step ============================= 382 | const onInternalStep = (up: boolean) => { 383 | // Ignore step since out of range 384 | if ((up && upDisabled) || (!up && downDisabled)) { 385 | return; 386 | } 387 | 388 | // Clear typing status since it may caused by up & down key. 389 | // We should sync with input value. 390 | userTypingRef.current = false; 391 | 392 | let stepDecimal = getMiniDecimal( 393 | shiftKeyRef.current ? getDecupleSteps(step) : step 394 | ); 395 | if (!up) { 396 | stepDecimal = stepDecimal.negate(); 397 | } 398 | 399 | const target = (decimalValue || getMiniDecimal(0)).add( 400 | stepDecimal.toString() 401 | ); 402 | 403 | const updatedValue = triggerValueUpdate(target, false); 404 | 405 | onStep?.(getDecimalValue(stringMode, updatedValue), { 406 | offset: shiftKeyRef.current ? getDecupleSteps(step) : step, 407 | type: up ? "up" : "down", 408 | }); 409 | 410 | inputRef.current?.focus(); 411 | }; 412 | 413 | // ============================ Flush ============================= 414 | /** 415 | * Flush current input content to trigger value change & re-formatter input if needed 416 | */ 417 | const flushInputValue = (userTyping: boolean) => { 418 | const parsedValue = getMiniDecimal(mergedParser(inputValue)); 419 | let formatValue: DecimalClass = parsedValue; 420 | 421 | if (!parsedValue.isNaN()) { 422 | // Only validate value or empty value can be re-fill to inputValue 423 | // Reassign the formatValue within ranged of trigger control 424 | formatValue = triggerValueUpdate(parsedValue, userTyping); 425 | } else { 426 | formatValue = decimalValue; 427 | } 428 | 429 | if (value !== undefined) { 430 | // Reset back with controlled value first 431 | setInputValue(decimalValue, false); 432 | } else if (!formatValue.isNaN()) { 433 | // Reset input back since no validate value 434 | setInputValue(formatValue, false); 435 | } 436 | }; 437 | 438 | const onKeyDown = (event: KeyboardEvent) => { 439 | const { which, shiftKey } = event; 440 | userTypingRef.current = true; 441 | 442 | if (shiftKey) { 443 | shiftKeyRef.current = true; 444 | } else { 445 | shiftKeyRef.current = false; 446 | } 447 | 448 | if (which === KeyCode.ENTER) { 449 | if (!compositionRef.current) { 450 | userTypingRef.current = false; 451 | } 452 | flushInputValue(false); 453 | onPressEnter?.(event); 454 | } 455 | 456 | if (keyboard === false) { 457 | return; 458 | } 459 | 460 | // Do step 461 | if (!compositionRef.current && [KeyCode.UP, KeyCode.DOWN].includes(which)) { 462 | onInternalStep(KeyCode.UP === which); 463 | event.preventDefault(); 464 | } 465 | }; 466 | 467 | const onKeyUp = () => { 468 | userTypingRef.current = false; 469 | shiftKeyRef.current = false; 470 | }; 471 | 472 | // >>> Focus & Blur 473 | const onBlur = () => { 474 | flushInputValue(false); 475 | 476 | setFocus(false); 477 | 478 | userTypingRef.current = false; 479 | }; 480 | 481 | // ========================== Controlled ========================== 482 | // Input by precision 483 | useLayoutUpdateEffect(() => { 484 | if (!decimalValue.isInvalidate()) { 485 | setInputValue(decimalValue, false); 486 | } 487 | }, [precision]); 488 | 489 | // Input by value 490 | useLayoutUpdateEffect(() => { 491 | const newValue = getMiniDecimal(value); 492 | setDecimalValue(newValue); 493 | 494 | const currentParsedValue = getMiniDecimal(mergedParser(inputValue)); 495 | 496 | // When user typing from `1.2` to `1.`, we should not convert to `1` immediately. 497 | // But let it go if user set `formatter` 498 | if ( 499 | !newValue.equals(currentParsedValue) || 500 | !userTypingRef.current || 501 | formatter 502 | ) { 503 | // Update value as effect 504 | setInputValue(newValue, userTypingRef.current); 505 | } 506 | }, [value]); 507 | 508 | // ============================ Cursor ============================ 509 | useLayoutUpdateEffect(() => { 510 | if (formatter) { 511 | restoreCursor(); 512 | } 513 | }, [inputValue]); 514 | 515 | const { classPrefix } = useContext(ConfigContext); 516 | 517 | // ============================ Render ============================ 518 | return ( 519 |
    { 533 | setFocus(true); 534 | }} 535 | onBlur={onBlur} 536 | onKeyDown={onKeyDown} 537 | onKeyUp={onKeyUp} 538 | onCompositionStart={onCompositionStart} 539 | onCompositionEnd={onCompositionEnd} 540 | > 541 |
    542 | 561 |
    562 |
    563 | ); 564 | }); 565 | 566 | InputNumber.displayName = "InputNumber"; 567 | 568 | export default InputNumber; 569 | -------------------------------------------------------------------------------- /src/libs/rc-input-number/hooks/useCursor.ts: -------------------------------------------------------------------------------- 1 | import { useRef } from 'preact/hooks'; 2 | import warning from 'rc-util/lib/warning'; 3 | /** 4 | * Keep input cursor in the correct position if possible. 5 | * Is this necessary since we have `formatter` which may mass the content? 6 | */ 7 | export default function useCursor( 8 | input: HTMLInputElement | null, 9 | focused: boolean, 10 | ): [() => void, () => void] { 11 | const selectionRef = useRef<{ 12 | start?: number; 13 | end?: number; 14 | value?: string; 15 | beforeTxt?: string; 16 | afterTxt?: string; 17 | }>(null); 18 | 19 | function recordCursor() { 20 | // Record position 21 | try { 22 | if (!input) return; 23 | 24 | const { selectionStart: start, selectionEnd: end, value } = input; 25 | const beforeTxt = value.substring(0, start ?? 0); 26 | const afterTxt = value.substring(end ?? 0); 27 | 28 | selectionRef.current = { 29 | start, 30 | end, 31 | value, 32 | beforeTxt, 33 | afterTxt, 34 | }; 35 | } catch (e) { 36 | // Fix error in Chrome: 37 | // Failed to read the 'selectionStart' property from 'HTMLInputElement' 38 | // http://stackoverflow.com/q/21177489/3040605 39 | } 40 | } 41 | 42 | /** 43 | * Restore logic: 44 | * 1. back string same 45 | * 2. start string same 46 | */ 47 | function restoreCursor() { 48 | if (input && selectionRef.current && focused) { 49 | try { 50 | const { value } = input; 51 | const { beforeTxt, afterTxt, start } = selectionRef.current; 52 | 53 | let startPos = value.length; 54 | if (!afterTxt || !beforeTxt || !selectionRef.current?.afterTxt) 55 | return; 56 | 57 | if (value.endsWith(afterTxt)) { 58 | startPos = value.length - selectionRef.current.afterTxt.length; 59 | } else if (value.startsWith(beforeTxt)) { 60 | startPos = beforeTxt.length; 61 | } else { 62 | if (!start) return; 63 | const beforeLastChar = beforeTxt[start - 1]; 64 | const newIndex = value.indexOf(beforeLastChar, start - 1); 65 | if (newIndex !== -1) { 66 | startPos = newIndex + 1; 67 | } 68 | } 69 | 70 | input.setSelectionRange(startPos, startPos); 71 | } catch (e) { 72 | warning( 73 | false, 74 | `Something warning of cursor restore. Please fire issue about this: ${e.message}`, 75 | ); 76 | } 77 | } 78 | } 79 | 80 | return [recordCursor, restoreCursor]; 81 | } 82 | -------------------------------------------------------------------------------- /src/libs/rc-input-number/hooks/useFrame.ts: -------------------------------------------------------------------------------- 1 | import { useRef, useEffect } from 'preact/hooks'; 2 | import raf from 'rc-util/lib/raf'; 3 | 4 | /** 5 | * Always trigger latest once when call multiple time 6 | */ 7 | export default () => { 8 | const idRef = useRef(0); 9 | 10 | const cleanUp = () => { 11 | raf.cancel(idRef.current); 12 | }; 13 | 14 | useEffect(() => cleanUp, []); 15 | 16 | return (callback: () => void) => { 17 | cleanUp(); 18 | 19 | idRef.current = raf(() => { 20 | callback(); 21 | }); 22 | }; 23 | }; 24 | -------------------------------------------------------------------------------- /src/libs/rc-input-number/index.ts: -------------------------------------------------------------------------------- 1 | import InputNumber from './InputNumber'; 2 | 3 | export default InputNumber; 4 | -------------------------------------------------------------------------------- /src/libs/rc-input-number/utils/MiniDecimal.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-classes-per-file */ 2 | 3 | import { getNumberPrecision, isE, num2str, trimNumber, validateNumber } from './numberUtil'; 4 | import { supportBigInt } from './supportUtil'; 5 | 6 | export type ValueType = string | number; 7 | 8 | export interface DecimalClass { 9 | add: (value: ValueType) => DecimalClass; 10 | 11 | isEmpty: () => boolean; 12 | 13 | isNaN: () => boolean; 14 | 15 | isInvalidate: () => boolean; 16 | 17 | toNumber: () => number; 18 | 19 | /** 20 | * Parse value as string. Will return empty string if `isInvalidate`. 21 | * You can set `safe=false` to get origin string content. 22 | */ 23 | toString: (safe?: boolean) => string; 24 | 25 | equals: (target: DecimalClass) => boolean; 26 | 27 | lessEquals: (target: DecimalClass) => boolean; 28 | 29 | negate: () => DecimalClass; 30 | } 31 | 32 | /** 33 | * We can remove this when IE not support anymore 34 | */ 35 | export class NumberDecimal implements DecimalClass { 36 | origin: string = ''; 37 | number: number; 38 | empty: boolean; 39 | 40 | constructor(value: ValueType) { 41 | if ((!value && value !== 0) || !String(value).trim()) { 42 | this.empty = true; 43 | return; 44 | } 45 | 46 | this.origin = String(value); 47 | this.number = Number(value); 48 | } 49 | 50 | negate() { 51 | return new NumberDecimal(-this.toNumber()); 52 | } 53 | 54 | add(value: ValueType) { 55 | if (this.isInvalidate()) { 56 | return new NumberDecimal(value); 57 | } 58 | 59 | const target = Number(value); 60 | 61 | if (Number.isNaN(target)) { 62 | return this; 63 | } 64 | 65 | const number = this.number + target; 66 | 67 | // [Legacy] Back to safe integer 68 | if (number > Number.MAX_SAFE_INTEGER) { 69 | return new NumberDecimal(Number.MAX_SAFE_INTEGER); 70 | } 71 | 72 | if (number < Number.MIN_SAFE_INTEGER) { 73 | return new NumberDecimal(Number.MIN_SAFE_INTEGER); 74 | } 75 | 76 | const maxPrecision = Math.max(getNumberPrecision(this.number), getNumberPrecision(target)); 77 | return new NumberDecimal(number.toFixed(maxPrecision)); 78 | } 79 | 80 | isEmpty() { 81 | return this.empty; 82 | } 83 | 84 | isNaN() { 85 | return Number.isNaN(this.number); 86 | } 87 | 88 | isInvalidate() { 89 | return this.isEmpty() || this.isNaN(); 90 | } 91 | 92 | equals(target: DecimalClass) { 93 | return this.toNumber() === target?.toNumber(); 94 | } 95 | 96 | lessEquals(target: DecimalClass) { 97 | return this.add(target.negate().toString()).toNumber() <= 0; 98 | } 99 | 100 | toNumber() { 101 | return this.number; 102 | } 103 | 104 | toString(safe: boolean = true) { 105 | if (!safe) { 106 | return this.origin; 107 | } 108 | 109 | if (this.isInvalidate()) { 110 | return ''; 111 | } 112 | 113 | return num2str(this.number); 114 | } 115 | } 116 | 117 | export class BigIntDecimal implements DecimalClass { 118 | origin: string = ''; 119 | negative: boolean; 120 | integer: bigint; 121 | decimal: bigint; 122 | /** BigInt will convert `0009` to `9`. We need record the len of decimal */ 123 | decimalLen: number; 124 | empty: boolean; 125 | nan: boolean; 126 | 127 | constructor(value: string | number) { 128 | if ((!value && value !== 0) || !String(value).trim()) { 129 | this.empty = true; 130 | return; 131 | } 132 | 133 | this.origin = String(value); 134 | 135 | // Act like Number convert 136 | if (value === '-') { 137 | this.nan = true; 138 | return; 139 | } 140 | 141 | let mergedValue = value; 142 | 143 | // We need convert back to Number since it require `toFixed` to handle this 144 | if (isE(mergedValue)) { 145 | mergedValue = Number(mergedValue); 146 | } 147 | 148 | mergedValue = typeof mergedValue === 'string' ? mergedValue : num2str(mergedValue); 149 | 150 | if (validateNumber(mergedValue)) { 151 | const trimRet = trimNumber(mergedValue); 152 | this.negative = trimRet.negative; 153 | const numbers = trimRet.trimStr.split('.'); 154 | this.integer = BigInt(numbers[0]); 155 | const decimalStr = numbers[1] || '0'; 156 | this.decimal = BigInt(decimalStr); 157 | this.decimalLen = decimalStr.length; 158 | } else { 159 | this.nan = true; 160 | } 161 | } 162 | 163 | private getMark() { 164 | return this.negative ? '-' : ''; 165 | } 166 | 167 | private getIntegerStr() { 168 | return this.integer.toString(); 169 | } 170 | 171 | private getDecimalStr() { 172 | return this.decimal.toString().padStart(this.decimalLen, '0'); 173 | } 174 | 175 | /** 176 | * Align BigIntDecimal with same decimal length. e.g. 12.3 + 5 = 1230000 177 | * This is used for add function only. 178 | */ 179 | private alignDecimal(decimalLength: number): bigint { 180 | const str = `${this.getMark()}${this.getIntegerStr()}${this.getDecimalStr().padEnd( 181 | decimalLength, 182 | '0', 183 | )}`; 184 | return BigInt(str); 185 | } 186 | 187 | negate() { 188 | const clone = new BigIntDecimal(this.toString()); 189 | clone.negative = !clone.negative; 190 | return clone; 191 | } 192 | 193 | add(value: ValueType): BigIntDecimal { 194 | if (this.isInvalidate()) { 195 | return new BigIntDecimal(value); 196 | } 197 | 198 | const offset = new BigIntDecimal(value); 199 | if (offset.isInvalidate()) { 200 | return this; 201 | } 202 | 203 | const maxDecimalLength = Math.max(this.getDecimalStr().length, offset.getDecimalStr().length); 204 | const myAlignedDecimal = this.alignDecimal(maxDecimalLength); 205 | const offsetAlignedDecimal = offset.alignDecimal(maxDecimalLength); 206 | 207 | const valueStr = (myAlignedDecimal + offsetAlignedDecimal).toString(); 208 | 209 | // We need fill string length back to `maxDecimalLength` to avoid parser failed 210 | const { negativeStr, trimStr } = trimNumber(valueStr); 211 | const hydrateValueStr = `${negativeStr}${trimStr.padStart(maxDecimalLength + 1, '0')}`; 212 | 213 | return new BigIntDecimal( 214 | `${hydrateValueStr.slice(0, -maxDecimalLength)}.${hydrateValueStr.slice(-maxDecimalLength)}`, 215 | ); 216 | } 217 | 218 | isEmpty() { 219 | return this.empty; 220 | } 221 | 222 | isNaN() { 223 | return this.nan; 224 | } 225 | 226 | isInvalidate() { 227 | return this.isEmpty() || this.isNaN(); 228 | } 229 | 230 | equals(target: DecimalClass) { 231 | return this.toString() === target?.toString(); 232 | } 233 | 234 | lessEquals(target: DecimalClass) { 235 | return this.add(target.negate().toString()).toNumber() <= 0; 236 | } 237 | 238 | toNumber() { 239 | if (this.isNaN()) { 240 | return NaN; 241 | } 242 | return Number(this.toString()); 243 | } 244 | 245 | toString(safe: boolean = true) { 246 | if (!safe) { 247 | return this.origin; 248 | } 249 | 250 | if (this.isInvalidate()) { 251 | return ''; 252 | } 253 | 254 | return trimNumber(`${this.getMark()}${this.getIntegerStr()}.${this.getDecimalStr()}`).fullStr; 255 | } 256 | } 257 | 258 | export default function getMiniDecimal(value: ValueType): DecimalClass { 259 | // We use BigInt here. 260 | // Will fallback to Number if not support. 261 | if (supportBigInt()) { 262 | return new BigIntDecimal(value); 263 | } 264 | return new NumberDecimal(value); 265 | } 266 | 267 | /** 268 | * Align the logic of toFixed to around like 1.5 => 2. 269 | * If set `cutOnly`, will just remove the over decimal part. 270 | */ 271 | export function toFixed(numStr: string, separatorStr: string, precision?: number, cutOnly = false) : string { 272 | if (numStr === '') { 273 | return ''; 274 | } 275 | const { negativeStr, integerStr, decimalStr } = trimNumber(numStr); 276 | const precisionDecimalStr = `${separatorStr}${decimalStr}`; 277 | 278 | const numberWithoutDecimal = `${negativeStr}${integerStr}`; 279 | 280 | if (precision && precision >= 0) { 281 | // We will get last + 1 number to check if need advanced number 282 | const advancedNum = Number(decimalStr[precision]); 283 | 284 | if (advancedNum >= 5 && !cutOnly) { 285 | const advancedDecimal = getMiniDecimal(numStr).add( 286 | `${negativeStr}0.${'0'.repeat(precision)}${10 - advancedNum}`, 287 | ); 288 | return toFixed(advancedDecimal.toString(), separatorStr, precision, cutOnly); 289 | } 290 | 291 | if (precision === 0) { 292 | return numberWithoutDecimal; 293 | } 294 | 295 | return `${numberWithoutDecimal}${separatorStr}${decimalStr 296 | .padEnd(precision, '0') 297 | .slice(0, precision)}`; 298 | } 299 | 300 | if (precisionDecimalStr === '.0') { 301 | return numberWithoutDecimal; 302 | } 303 | 304 | return `${numberWithoutDecimal}${precisionDecimalStr}`; 305 | } 306 | -------------------------------------------------------------------------------- /src/libs/rc-input-number/utils/numberUtil.ts: -------------------------------------------------------------------------------- 1 | import { supportBigInt } from './supportUtil'; 2 | 3 | /** 4 | * Format string number to readable number 5 | */ 6 | export function trimNumber(numStr: string) { 7 | let str = numStr.trim(); 8 | 9 | let negative = str.startsWith('-'); 10 | 11 | if (negative) { 12 | str = str.slice(1); 13 | } 14 | 15 | str = str 16 | // Remove decimal 0. `1.000` => `1.`, `1.100` => `1.1` 17 | .replace(/(\.\d*[^0])0*$/, '$1') 18 | // Remove useless decimal. `1.` => `1` 19 | .replace(/\.0*$/, '') 20 | // Remove integer 0. `0001` => `1`, 000.1' => `.1` 21 | .replace(/^0+/, ''); 22 | 23 | if (str.startsWith('.')) { 24 | str = `0${str}`; 25 | } 26 | 27 | const trimStr = str || '0'; 28 | const splitNumber = trimStr.split('.'); 29 | 30 | const integerStr = splitNumber[0] || '0'; 31 | const decimalStr = splitNumber[1] || '0'; 32 | 33 | if (integerStr === '0' && decimalStr === '0') { 34 | negative = false; 35 | } 36 | 37 | const negativeStr = negative ? '-' : ''; 38 | 39 | return { 40 | negative, 41 | negativeStr, 42 | trimStr, 43 | integerStr, 44 | decimalStr, 45 | fullStr: `${negativeStr}${trimStr}`, 46 | }; 47 | } 48 | 49 | export function isE(number: string | number) { 50 | const str = String(number); 51 | 52 | return !Number.isNaN(Number(str)) && str.includes('e'); 53 | } 54 | 55 | /** 56 | * [Legacy] Convert 1e-9 to 0.000000001. 57 | * This may lose some precision if user really want 1e-9. 58 | */ 59 | export function getNumberPrecision(number: string | number) { 60 | const numStr: string = String(number); 61 | 62 | if (isE(number)) { 63 | let precision = Number(numStr.slice(numStr.indexOf('e-') + 2)); 64 | 65 | const decimalMatch = numStr.match(/\.(\d+)/); 66 | if (decimalMatch?.[1]) { 67 | precision += decimalMatch[1].length; 68 | } 69 | return precision; 70 | } 71 | 72 | return numStr.includes('.') && validateNumber(numStr) 73 | ? numStr.length - numStr.indexOf('.') - 1 74 | : 0; 75 | } 76 | 77 | /** 78 | * Convert number (includes scientific notation) to -xxx.yyy format 79 | */ 80 | export function num2str(number: number): string { 81 | let numStr: string = String(number); 82 | if (isE(number)) { 83 | if (number > Number.MAX_SAFE_INTEGER) { 84 | return String(supportBigInt() ? BigInt(number).toString() : Number.MAX_SAFE_INTEGER); 85 | } 86 | 87 | if (number < Number.MIN_SAFE_INTEGER) { 88 | return String(supportBigInt() ? BigInt(number).toString() : Number.MIN_SAFE_INTEGER); 89 | } 90 | 91 | numStr = number.toFixed(getNumberPrecision(numStr)); 92 | } 93 | 94 | return trimNumber(numStr).fullStr; 95 | } 96 | 97 | export function validateNumber(num: string | number) { 98 | if (typeof num === 'number') { 99 | return !Number.isNaN(num); 100 | } 101 | 102 | // Empty 103 | if (!num) { 104 | return false; 105 | } 106 | 107 | return ( 108 | // Normal type: 11.28 109 | /^\s*-?\d+(\.\d+)?\s*$/.test(num) || 110 | // Pre-number: 1. 111 | /^\s*-?\d+\.\s*$/.test(num) || 112 | // Post-number: .1 113 | /^\s*-?\.\d+\s*$/.test(num) 114 | ); 115 | } 116 | 117 | export function getDecupleSteps(step: string | number) { 118 | const stepStr = typeof step === 'number' ? num2str(step) : trimNumber(step).fullStr; 119 | const hasPoint = stepStr.includes('.'); 120 | if (!hasPoint) { 121 | return step + '0'; 122 | } 123 | return trimNumber(stepStr.replace(/(\d)\.(\d)/g, '$1$2.')).fullStr; 124 | } 125 | -------------------------------------------------------------------------------- /src/libs/rc-input-number/utils/supportUtil.ts: -------------------------------------------------------------------------------- 1 | export function supportBigInt() { 2 | return typeof BigInt === 'function'; 3 | } -------------------------------------------------------------------------------- /src/libs/send.ts: -------------------------------------------------------------------------------- 1 | import bs58 from 'bs58'; 2 | import { sleep } from '@accessprotocol/js'; 3 | import { PromisePool } from '@supercharge/promise-pool'; 4 | import { 5 | Commitment, 6 | ComputeBudgetProgram, 7 | Connection, 8 | PublicKey, 9 | Transaction, 10 | TransactionInstruction, 11 | TransactionMessage, 12 | VersionedTransaction, 13 | } from '@solana/web3.js'; 14 | 15 | export const confirmTxs = async ( 16 | txs: Array, 17 | connection: Connection, 18 | txStatus = 'confirmed', 19 | ) => { 20 | for (let i = 0; i < 150; i += 1) { 21 | // we're spamming at the beginning, this raises the probability of the tx being included in the block 22 | if (i > 30) { 23 | // eslint-disable-next-line no-await-in-loop 24 | await sleep(1000); 25 | } 26 | let finished = true; 27 | for (const tx of txs) { 28 | if (!tx) { throw new Error('No transaction.'); } 29 | const sigBuffer = 30 | tx instanceof Transaction ? tx.signature : tx.signatures[0]; 31 | if (!sigBuffer) { throw new Error('No transaction signature.'); } 32 | const sig = bs58.encode(sigBuffer); 33 | // eslint-disable-next-line no-await-in-loop 34 | const statuses = await connection.getSignatureStatuses([sig], { 35 | searchTransactionHistory: true, 36 | }); 37 | if (!statuses || statuses.value.length === 0) { 38 | console.log('No statuses found.'); 39 | finished = false; 40 | continue; 41 | } 42 | const status = statuses.value[0]; 43 | const statusValue = status?.confirmationStatus; 44 | console.log('Confirmation status: ', statusValue); 45 | if (statusValue !== txStatus && statusValue !== 'finalized') { 46 | finished = false; 47 | console.log(`Resending tx: ${sig}`); 48 | if (txStatus === 'processed') { 49 | try { 50 | // eslint-disable-next-line no-await-in-loop 51 | await connection.sendRawTransaction(tx.serialize()); 52 | } catch (err) { 53 | console.error(err); 54 | throw new Error( 55 | 'REJECTED: We were unable to send transactions to Solana successfully. Please try again later.', 56 | ); 57 | } 58 | } 59 | } 60 | } 61 | if (finished) { return; } 62 | } 63 | throw new Error( 64 | 'We were unable to send transactions to Solana successfully. Please try again later.', 65 | ); 66 | }; 67 | 68 | const IX_BATCH_SIZE = 4; 69 | 70 | async function getSimulationUnits( 71 | connection: Connection, 72 | instructions: TransactionInstruction[], 73 | payer: PublicKey, 74 | ): Promise { 75 | const testInstructions = [ 76 | ComputeBudgetProgram.setComputeUnitLimit({ units: 1_400_000 }), 77 | ...instructions, 78 | ]; 79 | 80 | const testVersionedTxn = new VersionedTransaction( 81 | new TransactionMessage({ 82 | instructions: testInstructions, 83 | payerKey: payer, 84 | recentBlockhash: PublicKey.default.toString(), 85 | }).compileToV0Message(), 86 | ); 87 | 88 | const simulation = await connection.simulateTransaction(testVersionedTxn, { 89 | replaceRecentBlockhash: true, 90 | sigVerify: false, 91 | }); 92 | console.log('SIM: ', simulation); 93 | if (simulation.value.err) { 94 | throw new Error( 95 | `Error simulating transaction: ${JSON.stringify(simulation.value.err)}`, 96 | ); 97 | } 98 | return (simulation.value.unitsConsumed || 0) * 1.1; 99 | } 100 | 101 | async function getTransactionWithoutBlockhash( 102 | connection: Connection, 103 | instructions: TransactionInstruction[], 104 | payer: PublicKey, 105 | maxPriorityFeeLamports: number, 106 | ): Promise { 107 | const cus = await getSimulationUnits(connection, instructions, payer); 108 | 109 | const enhancedInstructions = [ 110 | ComputeBudgetProgram.setComputeUnitLimit({ units: cus }), 111 | ComputeBudgetProgram.setComputeUnitPrice({ 112 | microLamports: Math.floor((maxPriorityFeeLamports / cus) * 1e6), 113 | }), 114 | ...instructions, 115 | ]; 116 | 117 | return new VersionedTransaction( 118 | new TransactionMessage({ 119 | instructions: enhancedInstructions, 120 | payerKey: payer, 121 | recentBlockhash: PublicKey.default.toString(), 122 | }).compileToV0Message(), 123 | ); 124 | } 125 | 126 | export const sendTxDirectly = async ( 127 | instructions: TransactionInstruction[], 128 | signAllTransactions: 129 | | ((txs: VersionedTransaction[]) => Promise) 130 | | undefined, 131 | signTransaction: 132 | | ((tx: VersionedTransaction) => Promise) 133 | | undefined, 134 | connection: Connection, 135 | userPublicKey: PublicKey, 136 | maxPriorityFeeLamports: number, 137 | confirmationLevel: Commitment = 'confirmed', 138 | ): Promise => { 139 | if (!signAllTransactions && !signTransaction) { 140 | throw new Error('No sign transaction function provided.'); 141 | } 142 | 143 | const isHwWallet: boolean = 144 | localStorage.getItem('walletIsHWWallet') === 'true'; 145 | 146 | const ixBatches = []; 147 | for (let i = 0; i < instructions.length; i += IX_BATCH_SIZE) { 148 | const ixsBatch = instructions.slice(i, i + IX_BATCH_SIZE); 149 | ixBatches.push(ixsBatch); 150 | } 151 | 152 | const preparedTxs = await PromisePool.withConcurrency(10) 153 | .for(ixBatches) 154 | .process(async (ixBatch) => { 155 | return getTransactionWithoutBlockhash( 156 | connection, 157 | ixBatch, 158 | userPublicKey, 159 | Math.floor(maxPriorityFeeLamports / ixBatches.length), 160 | ); 161 | }); 162 | 163 | if (preparedTxs.errors.length > 0) { 164 | console.error('Error preparing TXs: ', preparedTxs.errors); 165 | throw new Error(`Error preparing transactions, please report this issue.`); 166 | } 167 | 168 | // signing them all 169 | if (signAllTransactions && !isHwWallet) { 170 | if (preparedTxs.results.length > 0) { 171 | const { 172 | value: { blockhash }, 173 | context, 174 | } = await connection.getLatestBlockhashAndContext(confirmationLevel); 175 | preparedTxs.results.forEach((tx) => { 176 | // eslint-disable-next-line no-param-reassign 177 | tx.message.recentBlockhash = blockhash; 178 | }); 179 | const signedTxes = await signAllTransactions(preparedTxs.results); 180 | const ppSends = await PromisePool.withConcurrency(10) 181 | .for(signedTxes) 182 | .process(async (signedTx) => { 183 | let someSuccess = false; 184 | try { 185 | for (let i = 0; i < 30; i += 1) { 186 | await connection.sendRawTransaction(signedTx.serialize(), { 187 | maxRetries: 0, 188 | minContextSlot: context.slot, 189 | preflightCommitment: confirmationLevel, 190 | skipPreflight: true, 191 | }); 192 | someSuccess = true; 193 | } 194 | } catch (err) { 195 | if (someSuccess) { 196 | console.log('Error in spamming: ', err); 197 | } else { 198 | console.error('Error sending TX: ', err); 199 | throw new Error('Unable to send transactions to Solana.'); 200 | } 201 | } 202 | console.log('TX sent: ', signedTx); 203 | const sig = bs58.encode(signedTx.signatures[0]); 204 | return sig; 205 | }); 206 | 207 | if (ppSends.errors.length > 0) { 208 | console.error('Error sending TXs: ', ppSends.errors); 209 | } 210 | 211 | console.log('TXs send: ', ppSends.results); 212 | if (ppSends.results.length > 0) { 213 | console.log('Confirming TXs....'); 214 | await confirmTxs(signedTxes, connection, confirmationLevel); 215 | } 216 | 217 | return ppSends.results; 218 | } 219 | console.warn('No transactions to sign.'); 220 | return undefined; 221 | } 222 | 223 | if (!signTransaction) { 224 | throw new Error('No sign transaction function provided.'); 225 | } 226 | 227 | const signedTxs: VersionedTransaction[] = []; 228 | 229 | if (preparedTxs.results.length > 0) { 230 | const txs = preparedTxs.results; 231 | console.log('TXes to sing and send: ', txs); 232 | 233 | const signatures: string[] = []; 234 | for (const tx of txs) { 235 | const { 236 | value: { blockhash }, 237 | context, 238 | } = await connection.getLatestBlockhashAndContext(confirmationLevel); 239 | tx.message.recentBlockhash = blockhash; 240 | const signedTx = await signTransaction(tx); 241 | signedTxs.push(signedTx); 242 | let someSuccess = false; 243 | try { 244 | for (let i = 0; i < 30; i += 1) { 245 | await connection.sendRawTransaction(signedTx.serialize(), { 246 | maxRetries: 0, 247 | minContextSlot: context.slot, 248 | preflightCommitment: confirmationLevel, 249 | skipPreflight: true, 250 | }); 251 | someSuccess = true; 252 | } 253 | } catch (err) { 254 | if (someSuccess) { 255 | console.log('Error in spamming: ', err); 256 | } else { 257 | console.error('Error sending TX: ', err); 258 | throw new Error('Unable to send transactions to Solana.'); 259 | } 260 | } 261 | 262 | const sig = bs58.encode(signedTx.signatures[0]); 263 | signatures.push(sig); 264 | console.log('Signature: ', sig); 265 | } 266 | 267 | await confirmTxs(signedTxs, connection, confirmationLevel); 268 | return signatures; 269 | } 270 | console.warn('No transactions to sign.'); 271 | return undefined; 272 | }; 273 | -------------------------------------------------------------------------------- /src/libs/utils.ts: -------------------------------------------------------------------------------- 1 | import { ClassValue, clsx } from 'clsx'; 2 | 3 | export const formatACSCurrency = (amount: number) => { 4 | const amountAsACS = amount; 5 | return parseFloat( 6 | parseFloat(amountAsACS.toString()).toFixed(2) 7 | ).toLocaleString('en-US', { 8 | useGrouping: true, 9 | }); 10 | }; 11 | 12 | export const formatPenyACSCurrency = (amount: number) => { 13 | const amountAsACS = amount / 10 ** 6; 14 | return parseFloat( 15 | parseFloat(amountAsACS.toString()).toFixed(2) 16 | ).toLocaleString('en-US', { 17 | useGrouping: true, 18 | }); 19 | }; 20 | 21 | export function sleep(ms: number) { 22 | return new Promise((resolve) => setTimeout(resolve, ms)); 23 | } 24 | 25 | export function clsxp(prefix: string, ...args: ClassValue[]) { 26 | return clsx(args.filter(Boolean).map((arg) => `${prefix}${arg}`)); 27 | } 28 | -------------------------------------------------------------------------------- /src/loader.ts: -------------------------------------------------------------------------------- 1 | import { Configurations } from "./models"; 2 | 3 | type MethodNames = "init" | "event"; 4 | export const DEFAULT_NAME = "_acs"; 5 | 6 | /** 7 | * Represents a model that is created in embedded script 8 | * as part of script initialization. 9 | */ 10 | interface LoaderObject { 11 | /** 12 | * Queue that accumulates method calls during downloading 13 | * and loading of widget's script file. 14 | */ 15 | q: Array<[MethodNames, {}]>; 16 | } 17 | 18 | /** 19 | * Loads widget instance. 20 | * 21 | * @param win Global window object which stores pre-loaded and post-loaded state of widget instance. 22 | * @param defaultConfig A configurations that are merged with user. 23 | * @param scriptElement The script tag that includes installation script and triggered loader. 24 | * @param render A method to be called once initialization done and DOM element for hosting widget is ready. 25 | */ 26 | export default ( 27 | win: Window, 28 | defaultConfig: Configurations, 29 | scriptElement: Element | null, 30 | render: (element: HTMLElement, config: Configurations) => void 31 | ) => { 32 | // get a hold of script tag instance, which has an 33 | // attribute `id` with unique identifier of the widget instance 34 | const instanceName = 35 | scriptElement?.attributes.getNamedItem("id")?.value ?? DEFAULT_NAME; 36 | const loaderObject: LoaderObject = win[instanceName]; 37 | if (!loaderObject || !loaderObject.q) { 38 | throw new Error( 39 | `Widget didn't find LoaderObject for instance [${instanceName}]. ` + 40 | `The loading script was either modified, no call to 'init' method was done ` + 41 | `or there is conflicting object defined in \`window.${instanceName}\` .` 42 | ); 43 | } 44 | 45 | // check that the widget is not loaded twice under the same name 46 | if (win[`loaded-${instanceName}`]) { 47 | throw new Error( 48 | `Widget with name [${instanceName}] was already loaded. ` + 49 | `This means you have multiple instances with same identifier (e.g. '${DEFAULT_NAME}')` 50 | ); 51 | } 52 | 53 | // this will an root element of the widget instance 54 | let targetElement: HTMLElement; 55 | 56 | // iterate over all methods that were called up until now 57 | for (let i = 0; i < loaderObject.q.length; i++) { 58 | const item = loaderObject.q[i]; 59 | const methodName = item[0]; 60 | if (i === 0 && methodName !== "init") { 61 | throw new Error( 62 | `Failed to start Widget [${instanceName}]. 'init' must be called before other methods.` 63 | ); 64 | } else if (i !== 0 && methodName === "init") { 65 | continue; 66 | } 67 | 68 | switch (methodName) { 69 | case "init": 70 | const loadedObject = Object.assign(defaultConfig, item[1]); 71 | if (loadedObject.debug) { 72 | console.log(`Starting widget [${instanceName}]`, loadedObject); 73 | } 74 | 75 | if (loadedObject.poolId == null) { 76 | throw new Error("You must provide 'poolId' in 'init' method."); 77 | } 78 | 79 | if (loadedObject.poolName == null) { 80 | throw new Error("You must provide 'poolName' in 'init' method."); 81 | } 82 | 83 | // the actual rendering of the widget 84 | const wrappingElement = loadedObject.element ?? win.document.body; 85 | targetElement = wrappingElement.appendChild( 86 | win.document.createElement("div") 87 | ); 88 | targetElement.setAttribute("id", `widget-${instanceName}`); 89 | render(targetElement, loadedObject); 90 | 91 | // store indication that widget instance was initialized 92 | win[`loaded-${instanceName}`] = true; 93 | break; 94 | // TODO: here you can handle additional async interactions 95 | // with the widget from page (e.q. `_hw('refreshStats')`) 96 | default: 97 | console.warn(`Unsupported method [${methodName}]`, item[1]); 98 | } 99 | } 100 | 101 | // once finished processing all async calls, we going 102 | // to convert LoaderObject into sync calls to methods 103 | win[instanceName] = (method: MethodNames, ...args: any[]) => { 104 | switch (method) { 105 | case "event": { 106 | targetElement?.dispatchEvent( 107 | new CustomEvent("widget-event", { detail: { name: args?.[0] } }) 108 | ); 109 | break; 110 | } 111 | default: 112 | console.warn(`Unsupported method [${method}]`, args); 113 | } 114 | }; 115 | }; 116 | -------------------------------------------------------------------------------- /src/main.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | /* >> components/wallet-adapter/ */ 6 | 7 | .acs__wallet_adapter_dropdown { 8 | @apply absolute mt-2 w-80 px-6 py-4 top-[100%] bg-stone-800 text-white rounded-[1rem] opacity-0; 9 | } 10 | 11 | .acs__wallet_adapter_dropdown_wrapper { 12 | @apply relative inline-block text-left font-sans; 13 | } 14 | 15 | button.acs__wallet_adapter_button_trigger { 16 | @apply bg-stone-400 text-stone-800 border-0 py-3 px-5 text-xl rounded-full; 17 | } 18 | 19 | .acs__wallet_adapter_button_trigger_active { 20 | @apply bg-indigo-400; 21 | } 22 | 23 | .acs__wallet_adapter_dropdown_active { 24 | @apply absolute visible opacity-100; 25 | } 26 | 27 | .acs__wallet_adapter_button { 28 | @apply bg-red-500 border-0 cursor-pointer flex items-center rounded-[4px]; 29 | } 30 | 31 | .acs__wallet_adapter_button_start_icon { 32 | @apply flex items-center justify-center w-[24px] h-[24px] ml-2; 33 | } 34 | 35 | .acs__wallet_adapter_button_end_icon { 36 | @apply flex items-center justify-center w-[24px] h-[24px] mr-2; 37 | } 38 | 39 | .acs__wallet_adapter_wallet_icon { 40 | @apply w-8 h-8 mr-8; 41 | } 42 | 43 | .acs__wallet_list_item { 44 | @apply text-white w-full text-left mt-4 text-xl bg-stone-700 rounded-full font-normal py-3 px-6 flex; 45 | } 46 | 47 | /* >> components/wallet-adapter/WalletModal.tsx */ 48 | 49 | .acs__wallet_adapter_modal { 50 | @apply absolute left-0 top-[110%] text-white; 51 | } 52 | 53 | .acs__wallet_adapter_modal_title { 54 | @apply text-2xl font-sans font-semibold text-center pb-2 px-10; 55 | } 56 | 57 | .acs__wallet_adapter_modal_title_para { 58 | @apply text-sm font-sans w-80 py-0 text-center; 59 | } 60 | 61 | .acs__wallet_adapter_modal_wrapper { 62 | @apply relative flex flex-col items-center justify-center pb-2; 63 | } 64 | 65 | .acs__wallet_adapter_modal_container { 66 | @apply rounded-[1rem] bg-stone-800 p-3 content-center items-center z-10; 67 | } 68 | 69 | .acs__wallet_adapter_modal_button_close { 70 | @apply absolute top-4 right-4 p-3 cursor-pointer bg-stone-800 border-0 fill-[#fff]; 71 | } 72 | 73 | .acs__wallet_adapter_modal_list { 74 | @apply list-none m-0 p-0 w-full; 75 | } 76 | 77 | .acs__wallet_adapter_modal_list_more { 78 | @apply cursor-pointer text-white bg-transparent border-0 px-4 py-2 mt-2 text-sm text-center self-center; 79 | } 80 | 81 | .acs__wallet_adapter_modal_list_more_icon_rotate { 82 | @apply transform rotate-0 pl-2; 83 | } 84 | 85 | .acs__wallet_adapter_modal_list_more_icon_rotate_expanded { 86 | @apply pl-2 transform rotate-180; 87 | } 88 | 89 | .acs__wallet_adapter_modal_middle { 90 | @apply flex flex-col align-middle justify-center; 91 | } 92 | 93 | .acs__wallet_adapter_modal_middle_button { 94 | @apply w-full block rounded-full mt-2 px-6 py-3 bg-stone-700 border-0 text-white cursor-pointer; 95 | } 96 | 97 | /* >> libs/rc-input-number/InputNumber.tsx */ 98 | 99 | .acs__rc_input_number_root { 100 | @apply text-xl w-auto pl-8 py-4 border-0 rounded-[0.5rem] bg-stone-900 text-stone-200 outline-none; 101 | } 102 | 103 | .acs__rc_input_number_root_focused { 104 | @apply block text-xl pl-8 py-4 border-0 rounded-[0.5rem] bg-stone-900 text-stone-200 outline-none ring-stone-900; 105 | } 106 | 107 | .acs__rc_input_number_root_disabled { 108 | @apply block bg-stone-500; 109 | } 110 | 111 | .acs__rc_input_number_root_readonly { 112 | @apply border-2 border-indigo-500; 113 | } 114 | 115 | .acs__rc_input_number_root_nan { 116 | @apply border-2 border-red-500; 117 | } 118 | 119 | .acs__rc_input_number_root_out_of_range { 120 | @apply border-2 border-red-500; 121 | } 122 | 123 | .acs__rc_input_number_wrap { 124 | @apply w-auto overflow-hidden; 125 | } 126 | 127 | .acs__rc_input_number_input { 128 | @apply text-stone-200 bg-stone-900 outline-none ring-stone-800 border-0 text-3xl; 129 | } 130 | 131 | /* >> components/Tooltip.tsx */ 132 | 133 | .acs__tooltip_root { 134 | @apply relative flex flex-row items-center justify-center px-2; 135 | } 136 | 137 | .acs__tooltip_root:hover .acs__tooltip_wrapper { 138 | @apply cursor-pointer flex visible; 139 | } 140 | 141 | .acs__tooltip_wrapper { 142 | @apply absolute bottom-0 mb-6 hidden w-80 flex-col items-center group-hover:flex; 143 | } 144 | 145 | .acs__tooltip_message { 146 | @apply relative z-10 rounded-md bg-stone-500 p-2 text-xs leading-none text-white shadow-lg; 147 | } 148 | 149 | .acs__tooltip_arrow { 150 | @apply -mt-2 h-3 w-3 rotate-45 bg-stone-500; 151 | } 152 | 153 | /* >> components/ProcessStep.tsx */ 154 | 155 | .acs__process_step_root { 156 | @apply flex items-center; 157 | } 158 | 159 | .acs__process_step_completed_icon_wrap { 160 | @apply relative ml-1 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-green-500; 161 | } 162 | 163 | .acs__process_step_completed_icon { 164 | @apply h-full w-full text-stone-300 font-bold; 165 | } 166 | 167 | .acs__process_step_completed_text { 168 | @apply ml-3 text-xl font-medium text-green-500; 169 | } 170 | 171 | .acs__process_step_current_icon { 172 | @apply ml-1 h-6 w-6 animate-spin text-indigo-500; 173 | } 174 | 175 | .acs__process_step_current_icon_circle { 176 | @apply opacity-25; 177 | } 178 | 179 | .acs__process_step_current_icon_path { 180 | @apply opacity-75; 181 | } 182 | 183 | .acs__process_step_current_text { 184 | @apply ml-3 text-xl font-medium text-indigo-600; 185 | } 186 | 187 | .acs__process_step_pending_icon_wrap { 188 | @apply relative ml-1 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-stone-500; 189 | } 190 | 191 | .acs__process_step_pending_icon { 192 | @apply h-full w-full text-stone-800; 193 | } 194 | 195 | .acs__process_step_pending_text { 196 | @apply ml-3 text-xl font-bold text-stone-600; 197 | } 198 | 199 | /* >> components/ProcessModal.tsx */ 200 | 201 | .acs__process_modal_title { 202 | @apply my-8 mt-16 text-white text-2xl text-center; 203 | } 204 | 205 | .acs__process_modal_subtitle { 206 | @apply text-center text-stone-400; 207 | } 208 | 209 | .acs__process_modal_steps { 210 | @apply flex flex-col justify-start my-4; 211 | } 212 | 213 | .acs__process_modal_steps_load { 214 | @apply mx-auto pb-20; 215 | } 216 | 217 | .acs__process_modal_button { 218 | @apply w-full rounded-full cursor-pointer no-underline font-bold py-4 block text-xl text-center bg-indigo-500 text-stone-700 border-0; 219 | } 220 | 221 | .acs__process_modal_button:hover, 222 | .acs__process_modal_button_selected { 223 | @apply bg-indigo-300 text-stone-800; 224 | } 225 | 226 | .acs__process_modal_button:disabled, 227 | .acs__process_modal_button_disabled { 228 | @apply bg-stone-600 cursor-not-allowed; 229 | } 230 | 231 | /* >> components/NumberInputWithSlider.tsx */ 232 | 233 | .acs__number_input_with_slider_root { 234 | @apply relative mt-6 mb-2; 235 | } 236 | 237 | .acs__number_input_with_slider_slider { 238 | @apply mt-4 block border-0 mx-1; 239 | } 240 | 241 | .acs__number_input_with_slider_minmax { 242 | @apply absolute top-0 right-0 mt-4 mr-8 text-2xl font-bold hover:cursor-pointer text-indigo-200; 243 | } 244 | 245 | /* >> components/Loading.tsx */ 246 | 247 | .acs__loading { 248 | @apply h-12 w-12 animate-spin text-indigo-500; 249 | } 250 | 251 | .acs__loading_circle { 252 | @apply opacity-25; 253 | } 254 | 255 | .acs__loading_path { 256 | @apply opacity-75; 257 | } 258 | 259 | /* >> components/Header.tsx */ 260 | 261 | .acs__header_content { 262 | @apply flex items-center justify-between; 263 | } 264 | 265 | .acs__header_dropdown_copy { 266 | @apply flex items-center cursor-pointer; 267 | } 268 | 269 | .acs__header_copy_text_wrap { 270 | @apply flex items-center cursor-pointer; 271 | } 272 | 273 | .acs__header_copy_text { 274 | @apply flex items-center cursor-pointer; 275 | } 276 | 277 | .acs__header_copy_text_base58 { 278 | @apply mr-2; 279 | } 280 | 281 | .acs__header_copied_text { 282 | @apply flex items-center cursor-pointer text-green-400; 283 | } 284 | 285 | .acs__header_explorer { 286 | @apply mx-1 text-white no-underline; 287 | } 288 | 289 | /* >> routes/Actions.tsx */ 290 | 291 | .acs__actions_root { 292 | @apply h-[31em] flex flex-col justify-between; 293 | } 294 | 295 | .acs__actions_links_wrapper { 296 | @apply my-4 mt-8 flex flex-col gap-3; 297 | } 298 | 299 | .acs__actions_actions_disconnect { 300 | @apply self-end cursor-pointer text-red-400 no-underline; 301 | } 302 | 303 | .acs__actions_logo { 304 | @apply mt-8 flex items-center justify-center; 305 | } 306 | 307 | .acs__actions_button { 308 | @apply rounded-full cursor-pointer no-underline font-bold py-4 block text-xl text-center text-indigo-500 bg-stone-700; 309 | } 310 | 311 | .acs__actions_button:hover, 312 | .acs__actions_button_selected { 313 | @apply bg-indigo-500 text-stone-800; 314 | } 315 | 316 | .acs__actions_button:disabled, 317 | .acs__actions_button_disabled:hover, 318 | .acs__actions_button_disabled { 319 | @apply bg-stone-500 text-stone-300 cursor-not-allowed; 320 | } 321 | 322 | .acs__actions_balance { 323 | @apply text-center text-stone-400; 324 | } 325 | 326 | .acs__actions_staked_amount { 327 | @apply text-xl text-white text-center my-3; 328 | } 329 | 330 | .acs__actions_loader { 331 | @apply: flex justify-center content-center; 332 | } 333 | 334 | .acs__actions_blink { 335 | @apply: animate-pulse; 336 | } 337 | 338 | /* >> routes/Claim.tsx */ 339 | 340 | .acs__claim_root { 341 | @apply h-[31em] flex flex-col justify-between; 342 | } 343 | 344 | .acs__claim_cancel_link { 345 | @apply self-end cursor-pointer text-blue-400 no-underline; 346 | } 347 | 348 | .acs__claim_button { 349 | @apply w-full rounded-full cursor-pointer no-underline font-bold py-4 block text-xl text-center bg-indigo-500 text-stone-700; 350 | } 351 | 352 | .acs__claim_button:hover { 353 | @apply bg-indigo-300 text-stone-800; 354 | } 355 | 356 | .acs__claim_title { 357 | @apply text-white text-2xl text-center; 358 | } 359 | 360 | .acs__claim_subtitle { 361 | @apply text-center text-stone-400 mb-14; 362 | } 363 | 364 | .acs__claim_claim_amount { 365 | @apply text-4xl text-center text-green-400; 366 | } 367 | 368 | .acs__claim_footnote { 369 | @apply flex justify-center text-sm text-indigo-500 mt-2 mb-2; 370 | } 371 | 372 | /* >> routes/Stake.tsx */ 373 | 374 | .acs__stake_root { 375 | @apply h-[31em] flex flex-col justify-between; 376 | } 377 | 378 | .acs__stake_cancel_link { 379 | @apply self-end cursor-pointer text-blue-400 no-underline; 380 | } 381 | 382 | .acs__stake_button { 383 | @apply w-full rounded-full cursor-pointer no-underline font-bold py-4 block text-xl text-center bg-indigo-500 text-stone-700 border-0; 384 | } 385 | 386 | .acs__forever_stake_button { 387 | @apply w-full rounded-full cursor-pointer no-underline font-bold py-4 block text-xl text-center bg-indigo-500 text-stone-700 border-0; 388 | } 389 | 390 | .acs__stake_checkbox { 391 | @apply w-full flex gap-3 pb-3 items-center justify-center text-red-300 392 | } 393 | 394 | .acs__stake_button:hover { 395 | @apply bg-indigo-300 text-stone-800; 396 | } 397 | 398 | .acs__forever_stake_button:hover { 399 | @apply bg-indigo-300 text-stone-800; 400 | } 401 | 402 | 403 | .acs__stake_button:disabled, 404 | .acs__stake_button:disabled:hover, 405 | .acs__stake_button_disabled, 406 | .acs__forever_stake_button:disabled, 407 | .acs__forever_stake_button:disabled:hover, 408 | .acs__forever_stake_button_disabled 409 | { 410 | @apply bg-stone-600 cursor-not-allowed; 411 | } 412 | 413 | .acs__stake_title { 414 | @apply my-8 mt-7 text-white text-3xl text-center; 415 | } 416 | 417 | .acs__stake_title_error { 418 | @apply mt-8 text-red-500 text-2xl text-center; 419 | } 420 | 421 | .acs__stake_subtitle { 422 | @apply text-center text-stone-400; 423 | } 424 | 425 | .acs__stake_subtitle_error { 426 | @apply text-red-500 text-center; 427 | } 428 | 429 | .acs__stake_fees_root { 430 | @apply mt-2 text-center text-xs text-stone-400; 431 | } 432 | 433 | .acs__stake_fee_with_tooltip { 434 | @apply flex justify-center; 435 | } 436 | 437 | .acs__stake_loader { 438 | @apply flex justify-center content-center mb-56; 439 | } 440 | 441 | .acs__stake_steps { 442 | @apply flex flex-col justify-start my-4; 443 | } 444 | 445 | .acs__stake_steps_list { 446 | @apply space-y-4 list-none mb-10; 447 | } 448 | 449 | .acs__stake_invalid { 450 | @apply bg-red-400; 451 | } 452 | 453 | .acs__stake_invalid_text { 454 | @apply mt-1 text-center text-red-500; 455 | } 456 | 457 | /* >> routes/Unstake.tsx */ 458 | 459 | .acs__unstake_root { 460 | @apply h-[31em] flex flex-col justify-between; 461 | } 462 | 463 | .acs__unstake_cancel_link { 464 | @apply self-end cursor-pointer text-blue-400 no-underline; 465 | } 466 | 467 | .acs__unstake_button { 468 | @apply w-full rounded-full cursor-pointer no-underline font-bold py-4 block text-xl text-center bg-indigo-500 text-stone-700; 469 | } 470 | 471 | .acs__unstake_button:hover { 472 | @apply bg-indigo-300 text-stone-800; 473 | } 474 | 475 | .acs__unstake_title { 476 | @apply text-white text-2xl text-center; 477 | } 478 | 479 | .acs__unstake_subtitle { 480 | @apply text-center text-stone-400 my-14; 481 | } 482 | 483 | .acs__unstake_footnote { 484 | @apply flex justify-center text-sm text-indigo-500 mt-2 mb-2; 485 | } 486 | -------------------------------------------------------------------------------- /src/models.ts: -------------------------------------------------------------------------------- 1 | interface InfraConfigurations { 2 | element?: HTMLElement; 3 | debug?: boolean; 4 | } 5 | 6 | /** 7 | * A model representing all possible configurations 8 | * that can be done from embedded script. Those settings 9 | * are passed around in application via Context. 10 | */ 11 | export interface AppConfigurations { 12 | poolId: string | null; 13 | poolName: string | null; 14 | disconnectButtonClass?: string | null; 15 | connectedButtonClass?: string | null; 16 | classPrefix: string; 17 | } 18 | 19 | export type Configurations = InfraConfigurations & AppConfigurations; 20 | -------------------------------------------------------------------------------- /src/routes/Actions.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { 3 | useCallback, 4 | useContext, 5 | useEffect, 6 | useMemo, 7 | useState, 8 | } from 'preact/hooks'; 9 | 10 | import { ConfigContext } from '../AppContext'; 11 | import { clsxp, formatPenyACSCurrency } from '../libs/utils'; 12 | import { RouteLink } from '../layout/Router'; 13 | import { Header } from '../components/Header'; 14 | import { useWallet } from '../components/wallet-adapter/useWallet'; 15 | import { useConnection } from '../components/wallet-adapter/useConnection'; 16 | import env from '../libs/env'; 17 | import { offchainBasicSubscriptionsSchema } from '../validations/subscriptions'; 18 | import { getUserACSBalance } from '../libs/program'; 19 | 20 | export const Actions = () => { 21 | const { poolId, classPrefix } = useContext(ConfigContext); 22 | const { publicKey, disconnect, disconnecting, connected } = useWallet(); 23 | const { connection } = useConnection(); 24 | const [balance, setBalance] = useState(null); 25 | const [stakedAmount, setStakedAmount] = useState(null); 26 | const [bondsAmount, setBondsAmount] = useState(null); 27 | const [foreverAmount, setForeverAmount] = useState(null); 28 | 29 | useEffect(() => { 30 | if (!(publicKey && connected)) { 31 | return; 32 | } 33 | (async () => { 34 | try { 35 | // Fetch balance using the old method 36 | const userBalance = await getUserACSBalance( 37 | connection, 38 | publicKey, 39 | env.PROGRAM_ID 40 | ); 41 | setBalance(userBalance.toNumber()); 42 | 43 | // Fetch other data from GO API 44 | const response = await fetch( 45 | `${env.GO_API_URL}/subscriptions/${publicKey.toBase58()}` 46 | ); 47 | if (!response.ok) { 48 | console.log('ERROR: ', response.statusText); 49 | return; 50 | } 51 | 52 | const json = await response.json(); 53 | const data = offchainBasicSubscriptionsSchema.parse(json); 54 | const { locked, bonds, forever } = data.reduce( 55 | (acc, item) => { 56 | if (item.pool === poolId) { 57 | return { 58 | locked: acc.locked + (item.locked ?? 0), 59 | bonds: acc.bonds + (item.bonds ?? 0), 60 | forever: acc.forever + (item?.forever ?? 0), 61 | }; 62 | } else { 63 | return acc; 64 | } 65 | }, 66 | { 67 | locked: 0, 68 | bonds: 0, 69 | forever: 0, 70 | } 71 | ); 72 | 73 | setStakedAmount(locked ?? 0); 74 | setBondsAmount(bonds ?? 0); 75 | setForeverAmount(forever ?? 0); 76 | } catch (error) { 77 | console.error('Failed to fetch data:', error); 78 | } 79 | })(); 80 | }, [publicKey, connected, poolId, connection]); 81 | 82 | const disconnectHandler = useCallback(async () => { 83 | try { 84 | await disconnect(); 85 | } catch (error) { 86 | console.error('Failed to disconnect:', error); 87 | } 88 | }, [disconnect]); 89 | 90 | const hasUnlockableAmount = useMemo(() => { 91 | return (stakedAmount ?? 0) + (bondsAmount ?? 0) > 0; 92 | }, [stakedAmount, bondsAmount]); 93 | 94 | const openClaimPage = useCallback(() => { 95 | window.open('https://hub.accessprotocol.co', '_blank'); 96 | }, []); 97 | 98 | return ( 99 |
    100 | {connected && disconnecting && ( 101 |
    102 |
    103 | Disconnecting... 104 |
    105 |
    106 | )} 107 | {connected && !disconnecting && ( 108 |
    109 |
    113 | Disconnect 114 |
    115 |
    116 | )} 117 | 118 |
    119 | 126 | 130 | 131 |
    132 | 133 |
    134 |
    135 |
    136 | {formatPenyACSCurrency( 137 | (stakedAmount ?? 0) + (bondsAmount ?? 0) + (foreverAmount ?? 0) 138 | )}{' '} 139 | ACS locked 140 |
    141 |
    142 |
    143 | {formatPenyACSCurrency(balance ?? 0)} ACS available 144 |
    145 |
    146 | 147 |
    148 | 152 | Lock 153 | 154 | {hasUnlockableAmount ? ( 155 | 159 | Unlock ACS 160 | 161 | ) : ( 162 | 169 | Unlock ACS 170 | 171 | )} 172 | 178 |
    179 |
    180 | ); 181 | }; 182 | -------------------------------------------------------------------------------- /src/routes/Stake.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment, h } from 'preact'; 2 | import { Info } from 'phosphor-react'; 3 | import { 4 | BondV2Account, 5 | CentralStateV2, 6 | fullLock, 7 | getBondV2Accounts, 8 | StakeAccount, 9 | StakePool, 10 | } from '@accessprotocol/js'; 11 | import { PublicKey } from '@solana/web3.js'; 12 | import { useContext, useEffect, useMemo, useState } from 'preact/hooks'; 13 | 14 | import { Header } from '../components/Header'; 15 | import { RouteLink } from '../layout/Router'; 16 | import { ConfigContext } from '../AppContext'; 17 | import { useConnection } from '../components/wallet-adapter/useConnection'; 18 | import { useWallet } from '../components/wallet-adapter/useWallet'; 19 | import { getStakeAccounts, getUserACSBalance, } from '../libs/program'; 20 | import { Tooltip } from '../components/Tooltip'; 21 | import { NumberInputWithSlider } from '../components/NumberInputWithSlider'; 22 | import Loading from '../components/Loading'; 23 | import { ProgressModal } from '../components/ProgressModal'; 24 | import { clsxp, formatACSCurrency } from '../libs/utils'; 25 | import env from '../libs/env'; 26 | import { sendTxDirectly } from '../libs/send'; 27 | 28 | const DONE_STEP = 'Done'; 29 | const IDLE_STEP = 'Idle'; 30 | 31 | const ACCOUNT_CREATION_ACS_PRICE = 50; 32 | 33 | const calculateFees = (amount: number, 34 | feeBasisPoints: number, 35 | forever: boolean, 36 | stakeAccount?: StakeAccount | null, 37 | bondV2Accounts?: BondV2Account[], 38 | ) => { 39 | let accountCreationFee = ACCOUNT_CREATION_ACS_PRICE; 40 | if ((!forever && stakeAccount) || (forever && bondV2Accounts && bondV2Accounts.length > 0)) { 41 | accountCreationFee = 0; 42 | } 43 | const protocolFee = forever ? 0 : amount * (feeBasisPoints / 10000); 44 | return protocolFee + accountCreationFee; 45 | }; 46 | 47 | export const Stake = () => { 48 | const { poolId, poolName, element, classPrefix } = useContext(ConfigContext); 49 | const { connection } = useConnection(); 50 | const { publicKey, signTransaction, signAllTransactions } = 51 | useWallet(); 52 | const [working, setWorking] = useState(IDLE_STEP); 53 | const [balance, setBalance] = useState(undefined); 54 | const [forever, setForever] = useState(false); 55 | const [stakeAccount, setStakeAccount] = useState< 56 | StakeAccount | undefined | null 57 | >(undefined); 58 | const [bondV2Accounts, setBondV2Accounts] = useState([]); 59 | const [stakedPool, setStakePool] = useState(null); 60 | const [stakeAmount, setStakeAmount] = useState(0); 61 | const [feeBasisPoints, setFeeBasisPoints] = useState(0); 62 | const [stakeModalOpen, setStakeModal] = useState(false); 63 | const [error, setError] = useState(null); 64 | 65 | const openStakeModal = () => setStakeModal(true); 66 | 67 | // set stake pool 68 | useEffect(() => { 69 | if (!poolId) { 70 | return; 71 | } 72 | (async () => { 73 | const sp = await StakePool.retrieve(connection, new PublicKey(poolId)); 74 | setStakePool(sp); 75 | })(); 76 | }, [poolId, setStakePool]); 77 | 78 | // set stake account 79 | useEffect(() => { 80 | if (!(publicKey && poolId && connection)) { 81 | return; 82 | } 83 | (async () => { 84 | const stakedAccounts = await getStakeAccounts( 85 | connection, 86 | publicKey, 87 | env.PROGRAM_ID 88 | ); 89 | if (stakedAccounts === null || stakedAccounts.length === 0) { 90 | setStakeAccount(null); 91 | return; 92 | } 93 | const sAccount = stakedAccounts.find((st) => { 94 | const sa = StakeAccount.deserialize(st.account.data); 95 | return sa.stakePool.toBase58() === poolId; 96 | }); 97 | if (sAccount) { 98 | const sa = StakeAccount.deserialize(sAccount.account.data); 99 | setStakeAccount(sa); 100 | } else { 101 | setStakeAccount(null); 102 | } 103 | })(); 104 | }, [publicKey, connection, poolId, setStakeAccount]); 105 | 106 | // set bond account 107 | useEffect(() => { 108 | if (!(publicKey && poolId && connection)) { 109 | return; 110 | } 111 | (async () => { 112 | const bV2Accounts = await getBondV2Accounts( 113 | connection, 114 | publicKey, 115 | env.PROGRAM_ID 116 | ); 117 | 118 | setBondV2Accounts( 119 | bV2Accounts.map((bAccount: any) => BondV2Account.deserialize(bAccount.account.data)) 120 | .filter((bAccount: BondV2Account) => bAccount.pool.toBase58() === poolId) 121 | ); 122 | })(); 123 | }, [publicKey, connection, poolId, setBondV2Accounts]); 124 | 125 | // set fee basis points from the central state 126 | useEffect(() => { 127 | if (!(publicKey && connection)) { 128 | return; 129 | } 130 | (async () => { 131 | const cs = await CentralStateV2.retrieve( 132 | connection, 133 | CentralStateV2.getKey(env.PROGRAM_ID)[0], 134 | ); 135 | setFeeBasisPoints(cs.feeBasisPoints); 136 | })(); 137 | }, [connection, setFeeBasisPoints]); 138 | 139 | // set ACS balance 140 | useEffect(() => { 141 | if (!(publicKey && connection)) { 142 | return; 143 | } 144 | (async () => { 145 | const b = await getUserACSBalance(connection, publicKey, env.PROGRAM_ID); 146 | const acsBalance = (b?.toNumber() || 0) / 10 ** 6; 147 | setBalance(acsBalance); 148 | })(); 149 | }, [publicKey, connection, stakeAccount, getUserACSBalance]); 150 | 151 | const minStakeAmount = useMemo(() => { 152 | const stakedAmount = Number(stakeAccount?.stakeAmount ?? 0) / 10 ** 6; 153 | const minPoolStakeAmount = (stakedPool?.minimumStakeAmount.toNumber() ?? 0) / 10 ** 6; 154 | const bondV2Amount = Number(bondV2Accounts.reduce((acc, ba) => acc + ba.amount.toNumber(), 0)) / 10 ** 6; 155 | const relevantLock = forever ? bondV2Amount : stakedAmount; 156 | return Math.max(minPoolStakeAmount - relevantLock, 1); 157 | }, [ 158 | stakedPool, 159 | stakeAccount?.stakeAmount, 160 | forever, 161 | ]); 162 | 163 | const maxStakeAmount = useMemo(() => { 164 | const max = Number(balance) - calculateFees( 165 | Number(balance), 166 | feeBasisPoints, 167 | forever, 168 | stakeAccount, 169 | bondV2Accounts, 170 | ); 171 | return max > 0 ? max : 0; 172 | }, [balance, feeBasisPoints, forever, stakeAccount, bondV2Accounts]); 173 | 174 | useEffect(() => { 175 | setStakeAmount(Math.max(maxStakeAmount, minStakeAmount)); 176 | }, [minStakeAmount, maxStakeAmount]); 177 | 178 | const fee = useMemo(() => { 179 | return calculateFees( 180 | stakeAmount, 181 | feeBasisPoints, 182 | forever, 183 | stakeAccount, 184 | bondV2Accounts, 185 | ); 186 | }, [stakeAmount, feeBasisPoints, forever, stakeAccount, bondV2Accounts]); 187 | 188 | const insufficientBalance = useMemo(() => { 189 | return ( 190 | minStakeAmount + fee > (balance ?? 0) 191 | ); 192 | }, [balance, minStakeAmount, fee]); 193 | 194 | console.log('minStakeAmount:', minStakeAmount); 195 | console.log('fee', fee); 196 | 197 | const invalidText = useMemo(() => { 198 | if (insufficientBalance) { 199 | return `Insufficient balance for locking. 200 | You need min. of ${formatACSCurrency( 201 | minStakeAmount + fee) 202 | } ACS (including ACS fees).`; 203 | } 204 | return null; 205 | }, [ 206 | insufficientBalance, 207 | minStakeAmount, 208 | fee, 209 | ]); 210 | 211 | const handle = async () => { 212 | if ( 213 | !(publicKey && poolId && connection && balance && stakedPool) 214 | ) { 215 | return; 216 | } 217 | 218 | try { 219 | openStakeModal(); 220 | 221 | const ixs = await fullLock( 222 | connection, 223 | publicKey, 224 | new PublicKey(poolId), 225 | publicKey, 226 | Number(stakeAmount), 227 | Date.now() / 1000, 228 | 0, 229 | env.PROGRAM_ID, 230 | undefined, 231 | stakedPool, 232 | forever ? 0 : -1, 233 | ); 234 | 235 | const result = await sendTxDirectly( 236 | ixs, 237 | signAllTransactions, 238 | signTransaction, 239 | connection, 240 | publicKey, 241 | 1_000_000, // todo dynamic 242 | ); 243 | 244 | console.log('SIGNATURE:', result); 245 | 246 | const lockedEvent = new CustomEvent('lock', { 247 | detail: { 248 | address: publicKey.toBase58(), 249 | amount: Number(stakeAmount) * 10 ** 6, 250 | }, 251 | bubbles: true, 252 | cancelable: true, 253 | composed: false, // if you want to listen on parent turn this on 254 | }); 255 | element?.dispatchEvent(lockedEvent); 256 | 257 | setWorking(DONE_STEP); 258 | } catch (err) { 259 | if (err instanceof Error) { 260 | console.error(err); 261 | setError(err.message); 262 | } 263 | } finally { 264 | setWorking(DONE_STEP); 265 | } 266 | }; 267 | 268 | return ( 269 |
    270 | {stakeModalOpen && error && ( 271 | 272 |
    273 | Error occured: 274 |
    275 |
    276 | {error} 277 |
    278 | 279 | Close 280 | 281 |
    282 | )} 283 | {stakeModalOpen && !error && ( 284 | 288 | )} 289 | {!stakeModalOpen && ( 290 | 291 |
    292 | 296 | Cancel 297 | 298 |
    299 | 300 | {stakeAccount !== undefined && 301 | bondV2Accounts !== undefined && 302 | balance !== undefined && ( 303 | 304 |
    305 | {poolName} 306 |
    307 | {!insufficientBalance ? ( 308 |
    309 | Both {poolName} and you will get ACS rewards 310 | split equally. 311 |
    312 | ) : ( 313 |

    314 | {invalidText} 315 |

    316 | )} 317 | 318 |
    319 | {insufficientBalance && ( 320 | 330 | Get ACS/SOL on access 331 | 332 | )} 333 | {!insufficientBalance && ( 334 | <> 335 | { 343 | setStakeAmount(value); 344 | }} 345 | /> 346 |
    347 | { 350 | 351 | setForever(!forever); 352 | }} 353 | checked={forever} 354 | /> 355 | Forever Lock 358 | 364 | 365 | 366 |
    367 | {!forever ? 368 | () : () 381 | } 382 | 383 | )} 384 | 385 |
    386 |
    389 | {fee > 0 ? ( 390 | <> 391 |
    Fees: {formatACSCurrency(fee)} ACS
    392 | 398 | 399 | 400 | 401 | ) : ( 402 |
    No additional fees
    403 | )} 404 |
    405 |
    406 |
    407 |
    408 | )} 409 | {(stakeAccount === undefined || 410 | bondV2Accounts === undefined || 411 | stakedPool == null || 412 | balance === undefined) && ( 413 |
    414 | 415 |
    416 | )} 417 |
    418 | )} 419 |
    420 | ); 421 | }; 422 | -------------------------------------------------------------------------------- /src/routes/Unstake.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | 3 | import { Header } from '../components/Header'; 4 | import { RouteLink } from '../layout/Router'; 5 | import { useContext } from 'preact/hooks'; 6 | import { ConfigContext } from '../AppContext'; 7 | import env from '../libs/env'; 8 | import clsx from 'clsx'; 9 | import { clsxp } from '../libs/utils'; 10 | 11 | export const Unstake = () => { 12 | const { poolId, classPrefix } = useContext(ConfigContext); 13 | 14 | return ( 15 |
    16 |
    17 | 21 | Cancel 22 | 23 |
    24 | 25 |
    Unlock ACS
    26 |
    27 | ACS unlocking is currently only possible in the Access app. 28 |
    29 | 30 |
    31 | 37 | Unlock ACS on Access 38 | 39 | 40 |
    41 | This will redirect you to accessprotocol.co 42 |
    43 |
    44 |
    45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /src/validations/subscriptions.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const serverOffchainBasicSubscription = z.object({ 4 | AssetId: z.string().optional(), 5 | Owner: z.string(), 6 | Pool: z.string(), 7 | Locked: z.number().optional(), 8 | Bonds: z.number().optional(), 9 | Forever: z.number().optional(), 10 | CreatedAt: z.coerce.date(), 11 | UpdatedAt: z.coerce.date().optional(), 12 | }); 13 | 14 | export const serverOffchainBasicSubscriptions = z.array( 15 | serverOffchainBasicSubscription, 16 | ); 17 | 18 | export const offchainBasicSubscriptionSchema = 19 | serverOffchainBasicSubscription.transform((item) => ({ 20 | assetId: item.AssetId, 21 | owner: item.Owner, 22 | pool: item.Pool, 23 | locked: item.Locked, 24 | bonds: item.Bonds, 25 | forever: item.Forever, 26 | createdAt: item.CreatedAt, 27 | updatedAt: item.UpdatedAt, 28 | })); 29 | 30 | export const offchainBasicSubscriptionsSchema = 31 | serverOffchainBasicSubscriptions.transform((items) => 32 | items.map((item) => ({ 33 | assetId: item.AssetId, 34 | owner: item.Owner, 35 | pool: item.Pool, 36 | locked: item.Locked, 37 | bonds: item.Bonds, 38 | forever: item.Forever, 39 | createdAt: item.CreatedAt, 40 | updatedAt: item.UpdatedAt, 41 | })), 42 | ); 43 | 44 | export type OffchainBasicSubscription = z.infer< 45 | typeof offchainBasicSubscriptionSchema 46 | >; 47 | 48 | export type OffchainBasicSubscriptions = z.infer< 49 | typeof offchainBasicSubscriptionsSchema 50 | >; 51 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ['./src/**/*.{js,jsx,ts,tsx}'], 4 | theme: { 5 | extend: { 6 | colors: { 7 | 'indigo': { 8 | 50: '#E5EDFF', 9 | 100: '#D1DEFF', 10 | 200: '#B2C8FF', 11 | 300: '#99B5FF', 12 | 400: '#84A7FF', 13 | 500: '#749BFF', 14 | 600: '#5582F6', 15 | 700: '#396EF4', 16 | 800: '#1E4DC7', 17 | 900: '#163480', 18 | }, 19 | }, 20 | }, 21 | }, 22 | plugins: [], 23 | } 24 | -------------------------------------------------------------------------------- /test/common.ts: -------------------------------------------------------------------------------- 1 | import { Configurations } from '../src/models'; 2 | 3 | export const testConfig = (override?: {}): Configurations => 4 | Object.assign( 5 | { 6 | poolId: '1', 7 | poolName: 'name', 8 | }, 9 | override 10 | ); 11 | 12 | /** This closely replicates what installation script does on page (e.g. /dev/index.html) */ 13 | export const install = (name: string, config?: Partial) => { 14 | const w = window; 15 | // tslint:disable-next-line: only-arrow-functions 16 | w[name] = 17 | w[name] || 18 | function () { 19 | (w[name].q = w[name].q || []).push(arguments); 20 | }; 21 | w[name]('init', config); 22 | }; 23 | 24 | export const currentScript = (name: string) => { 25 | const d = window.document; 26 | const js = d.createElement('script'); 27 | js.id = name; 28 | return js; 29 | }; 30 | 31 | export const randomNumber = (max: number = 5): number => { 32 | return Math.floor(Math.random() * Math.floor(max)); 33 | }; 34 | 35 | export const randomStr = (length: number = 5): string => { 36 | let text = ''; 37 | const possible = 38 | 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; 39 | 40 | for (let i = 0; i < length; i++) { 41 | text += possible.charAt(randomNumber(possible.length)); 42 | } 43 | 44 | return text; 45 | }; 46 | -------------------------------------------------------------------------------- /test/loader.spec.ts: -------------------------------------------------------------------------------- 1 | import loader, { DEFAULT_NAME } from '../src/loader'; 2 | import { install, testConfig, randomStr, currentScript } from './common'; 3 | import { Configurations } from '../src/models'; 4 | 5 | describe('loader', () => { 6 | it('should throw error if poolId not provided', () => { 7 | // arrange 8 | const expectedName = DEFAULT_NAME; 9 | install(expectedName); 10 | const renderMock = jest.fn(); 11 | 12 | // act 13 | expect(() => 14 | loader(window, testConfig({ poolId: null }), null, renderMock) 15 | ).toThrowError("You must provide 'poolId' in 'init' method."); 16 | }); 17 | 18 | it('should throw error if poolName not provided', () => { 19 | // arrange 20 | const expectedName = DEFAULT_NAME; 21 | install(expectedName); 22 | const renderMock = jest.fn(); 23 | 24 | // act 25 | expect(() => 26 | loader( 27 | window, 28 | testConfig({ poolId: '1', poolName: null }), 29 | null, 30 | renderMock 31 | ) 32 | ).toThrowError("You must provide 'poolName' in 'init' method."); 33 | }); 34 | 35 | it('should load single default instance', () => { 36 | // arrange 37 | const expectedName = DEFAULT_NAME; 38 | install(expectedName); 39 | const renderMock = jest.fn(); 40 | 41 | // act 42 | loader(window, testConfig(), null, renderMock); 43 | 44 | // assert 45 | expect(window[expectedName]).toBeDefined(); 46 | expect(window['loaded-' + expectedName]).toBeDefined(); 47 | expect(renderMock).toBeCalled(); 48 | }); 49 | 50 | it('should load single named instance', () => { 51 | // arrange 52 | const expectedName = randomStr(5); 53 | install(expectedName); 54 | const renderMock = jest.fn(); 55 | 56 | // act 57 | loader(window, testConfig(), currentScript(expectedName), renderMock); 58 | 59 | // assert 60 | expect(window[expectedName]).toBeDefined(); 61 | expect(window['loaded-' + expectedName]).toBeDefined(); 62 | expect(renderMock).toBeCalled(); 63 | }); 64 | 65 | it('should load multiple named instance', () => { 66 | // arrange 67 | const expectedName1 = randomStr(5); 68 | const expectedName2 = randomStr(5); 69 | const expectedConfig1 = { poolId: '1' }; 70 | const expectedConfig2 = { poolId: '2' }; 71 | install(expectedName1, expectedConfig1); 72 | install(expectedName2, expectedConfig2); 73 | 74 | const renderMock1 = jest.fn( 75 | (_: HTMLElement, __: Configurations) => undefined 76 | ); 77 | const renderMock2 = jest.fn( 78 | (_: HTMLElement, __: Configurations) => undefined 79 | ); 80 | 81 | // act 82 | loader(window, testConfig(), currentScript(expectedName1), renderMock1); 83 | loader(window, testConfig(), currentScript(expectedName2), renderMock2); 84 | 85 | // assert 86 | expect(window[expectedName1]).toBeDefined(); 87 | expect(window[expectedName2]).toBeDefined(); 88 | 89 | expect(window['loaded-' + expectedName1]).toBeDefined(); 90 | expect(window['loaded-' + expectedName2]).toBeDefined(); 91 | 92 | expect(renderMock1).toBeCalledWith( 93 | expect.anything(), 94 | expect.objectContaining(expectedConfig1) 95 | ); 96 | expect(renderMock2).toBeCalledWith( 97 | expect.anything(), 98 | expect.objectContaining(expectedConfig2) 99 | ); 100 | }); 101 | }); 102 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/recommended/tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "target": "es6", 6 | "lib": ["es6", "dom"], 7 | "moduleResolution": "node", 8 | "esModuleInterop": true, 9 | "rootDir": "./", 10 | "sourceMap": true, 11 | "allowJs": false, 12 | "noImplicitAny": true, 13 | "noUnusedLocals": true, 14 | "noImplicitThis": true, 15 | "strictNullChecks": true, 16 | "noImplicitReturns": true, 17 | "preserveConstEnums": true, 18 | "forceConsistentCasingInFileNames": true, 19 | "outDir": ".build", 20 | "jsx": "preserve", 21 | "jsxFactory": "h", 22 | "jsxFragmentFactory": "Fragment", 23 | "skipLibCheck": true, 24 | "paths": { 25 | "react": ["./node_modules/preact/compat/"], 26 | "react-dom": ["./node_modules/preact/compat/"] 27 | } 28 | }, 29 | "exclude": ["**/node_modules", "access-protocol"], 30 | "types": ["typePatches"] 31 | } 32 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": ["tslint:recommended"], 4 | "jsRules": {}, 5 | "rules": { 6 | "eofline": false, 7 | "no-console": false, 8 | "quotemark": [true, "single", "avoid-escape"], 9 | "semicolon": [true, "always", "ignore-bound-class-methods"], 10 | "whitespace": [true, "check-module"], 11 | "variable-name": [ 12 | true, 13 | "ban-keywords", 14 | "check-format", 15 | "allow-pascal-case" 16 | ], 17 | "no-trailing-whitespace": true, 18 | "trailing-comma": false, 19 | "no-string-literal": false, 20 | "interface-name": [true, "never-prefix"], 21 | "ordered-imports": false, 22 | "object-literal-sort-keys": false 23 | }, 24 | "rulesDirectory": [], 25 | "linterOptions": { 26 | "exclude": ["export/**/*"] 27 | }, 28 | "settings": { 29 | "react": { 30 | "pragma": "h" 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /typings.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import 'twin.macro'; 4 | import styledComponent, { css as cssProperty } from 'styled-components'; 5 | declare module 'twin.macro' { 6 | const css: typeof cssProperty; 7 | const styled: typeof styledComponent; 8 | } 9 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const webpack = require("webpack"); 3 | const Dotenv = require("dotenv-webpack"); 4 | 5 | const CopyPlugin = require("copy-webpack-plugin"); 6 | const StatoscopeWebpackPlugin = require("@statoscope/webpack-plugin").default; 7 | const { DuplicatesPlugin } = require("inspectpack/plugin"); 8 | const MiniCssExtractPlugin = require("mini-css-extract-plugin"); 9 | const CompressionPlugin = require("compression-webpack-plugin"); 10 | 11 | const bundleOutputDir = "./dist"; 12 | 13 | module.exports = (env) => { 14 | console.log("ENVs", env); 15 | 16 | let devtool = "inline-source-map"; 17 | const isDevBuild = env.TARGET_ENV === "development"; 18 | let plugins = []; 19 | 20 | switch (env.TARGET_ENV) { 21 | case "development": 22 | plugins = [ 23 | new Dotenv({ 24 | path: path.join(__dirname, ".env.development"), 25 | }), 26 | new webpack.ProvidePlugin({ 27 | process: "process/browser", 28 | Buffer: ["buffer", "Buffer"], 29 | }), 30 | new StatoscopeWebpackPlugin(), 31 | new CopyPlugin([{ from: "html-dev/" }]), 32 | new MiniCssExtractPlugin({ 33 | filename: "[name].css", 34 | }), 35 | new DuplicatesPlugin({ 36 | // Emit compilation warning or error? (Default: `false`) 37 | emitErrors: false, 38 | // Handle all messages with handler function (`(report: string)`) 39 | // Overrides `emitErrors` output. 40 | emitHandler: undefined, 41 | // List of packages that can be ignored. (Default: `[]`) 42 | // - If a string, then a prefix match of `{$name}/` for each module. 43 | // - If a regex, then `.test(pattern)` which means you should add slashes 44 | // where appropriate. 45 | // 46 | // **Note**: Uses posix paths for all matching (e.g., on windows `/` not `\`). 47 | ignoredPackages: undefined, 48 | // Display full duplicates information? (Default: `false`) 49 | verbose: true, 50 | }), 51 | ]; 52 | break; 53 | case "production": 54 | devtool = false; 55 | plugins = [ 56 | new Dotenv({ 57 | path: path.join(__dirname, ".env.production"), 58 | allowEmptyValues: false, 59 | }), 60 | new webpack.ProvidePlugin({ 61 | process: "process/browser", 62 | Buffer: ["buffer", "Buffer"], 63 | }), 64 | new StatoscopeWebpackPlugin(), 65 | new CopyPlugin([{ from: "html-production/" }]), 66 | new MiniCssExtractPlugin({ 67 | filename: "[name].css", 68 | }), 69 | new CompressionPlugin({ 70 | test: /\.(js|css)$/, // Compress .js and .css files 71 | algorithm: "gzip", // Default compression algorithm 72 | threshold: 10240, // Only assets bigger than this size (10 Kilobytes) are processed 73 | minRatio: 0.8 // Only assets that compress better than this ratio are processed 74 | }), 75 | ]; 76 | break; 77 | case "staging": 78 | devtool = false; 79 | plugins = [ 80 | new Dotenv({ 81 | path: path.join(__dirname, ".env.staging"), 82 | allowEmptyValues: false, 83 | }), 84 | new webpack.ProvidePlugin({ 85 | process: "process/browser", 86 | Buffer: ["buffer", "Buffer"], 87 | }), 88 | new StatoscopeWebpackPlugin(), 89 | new CopyPlugin([{ from: "html-staging/" }]), 90 | new MiniCssExtractPlugin({ 91 | filename: "[name].css", 92 | }), 93 | new CompressionPlugin({ 94 | test: /\.(js|css)$/, // Compress .js and .css files 95 | algorithm: "gzip", // Default compression algorithm 96 | threshold: 10240, // Only assets bigger than this size (10 Kilobytes) are processed 97 | minRatio: 0.8 // Only assets that compress better than this ratio are processed 98 | }), 99 | ]; 100 | break; 101 | default: 102 | throw new Error(`Unsupported TARGET_ENV: ${env.TARGET_ENV}`); 103 | } 104 | 105 | return [ 106 | { 107 | entry: "./src/index.ts", 108 | devtool: devtool, 109 | output: { 110 | filename: "widget.js", 111 | path: path.resolve(bundleOutputDir), 112 | }, 113 | devServer: { 114 | static: bundleOutputDir, 115 | }, 116 | plugins: plugins, 117 | optimization: { 118 | minimize: !isDevBuild, 119 | }, 120 | mode: isDevBuild ? "development" : "production", 121 | module: { 122 | rules: [ 123 | // packs SVG's discovered in url() into bundle 124 | { test: /\.svg/, use: "svg-url-loader" }, 125 | { 126 | test: /\.css$/i, 127 | use: [ 128 | { loader: MiniCssExtractPlugin.loader }, 129 | { 130 | loader: "css-loader", 131 | }, 132 | { 133 | loader: "postcss-loader", 134 | options: { 135 | postcssOptions: { 136 | plugins: [ 137 | require("autoprefixer")(), 138 | require("tailwindcss")(), 139 | ], 140 | }, 141 | }, 142 | }, 143 | ], 144 | sideEffects: true, 145 | }, 146 | // use babel-loader for TS and JS modeles, 147 | // starting v7 Babel babel-loader can transpile TS into JS, 148 | // so no need for ts-loader 149 | // note, that in dev we still use tsc for type checking 150 | { 151 | test: /\.(js|ts|tsx|jsx)$/, 152 | exclude: [/node_modules/, /access-protocol/], 153 | use: [ 154 | { 155 | loader: "babel-loader", 156 | options: { 157 | presets: [ 158 | ["@babel/preset-env"], 159 | [ 160 | // enable transpiling ts => js 161 | "@babel/typescript", 162 | // tell babel to compile JSX using into Preact 163 | { jsxPragma: "h" }, 164 | ], 165 | ], 166 | plugins: [ 167 | // syntax sugar found in React components 168 | "@babel/proposal-class-properties", 169 | "@babel/proposal-object-rest-spread", 170 | // transpile JSX/TSX to JS 171 | [ 172 | "@babel/plugin-transform-react-jsx", 173 | { 174 | // we use Preact, which has `Preact.h` instead of `React.createElement` 175 | pragma: "h", 176 | pragmaFrag: "Fragment", 177 | }, 178 | ], 179 | ], 180 | }, 181 | }, 182 | ], 183 | }, 184 | ], 185 | }, 186 | resolve: { 187 | extensions: ["*", ".js", ".ts", ".tsx"], 188 | alias: { 189 | react: "preact/compat", 190 | "react-dom/test-utils": "preact/test-utils", 191 | "react-dom": "preact/compat", // Must be below test-utils 192 | "react/jsx-runtime": "preact/jsx-runtime", 193 | }, 194 | fallback: { 195 | crypto: require.resolve("crypto-browserify"), 196 | stream: require.resolve("stream-browserify"), 197 | path: require.resolve("path-browserify"), 198 | buffer: require.resolve("buffer"), 199 | zlib: require.resolve("browserify-zlib"), 200 | }, 201 | }, 202 | }, 203 | ]; 204 | }; 205 | --------------------------------------------------------------------------------