├── .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 |
41 |
42 | {working !== doneStepName && }
43 |
44 |
55 | {isButtonDisabled && countdown > 0 ? `Close (${countdown})` : 'Close'}
56 |
57 |
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 |
29 | {props.startIcon && (
30 |
31 | {props.startIcon}
32 |
33 | )}
34 | {props.children}
35 | {props.endIcon && (
36 |
37 | {props.endIcon}
38 |
39 | )}
40 |
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 |
42 | {content}
43 |
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 |
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 | }
29 | tabIndex={tabIndex}
30 | >
31 | {wallet.adapter.name}
32 |
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 |
247 | {expanded ? "Less " : "More "}options
248 |
266 |
267 |
268 |
269 | ) : null}
270 |
271 | ) : (
272 |
273 |
276 | You'll need a wallet
277 |
278 |
281 |
288 | handleWalletClick(event, getStartedWallet.adapter.name)
289 | }
290 | >
291 | Get started
292 |
293 |
294 | {otherWallets.length ? (
295 |
296 |
304 |
305 | {expanded ? "Hide " : "Already have a wallet? View "}
306 | options
307 |
308 |
326 |
327 |
328 |
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 |
32 | {children}
33 |
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 |
143 | {content}
144 |
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 |
106 | )}
107 | {connected && !disconnecting && (
108 |
109 |
113 | Disconnect
114 |
115 |
116 | )}
117 |
118 |
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 |
176 | View on HUB
177 |
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 | (
373 | Lock
374 | ) : (
379 | Forever Lock
380 | )
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 |
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 |
--------------------------------------------------------------------------------