├── .depcheckrc.json ├── .editorconfig ├── .eslintrc.js ├── .gitattributes ├── .github ├── CODEOWNERS ├── dependabot.yml ├── pull_request_template.md └── workflows │ ├── build-lint-test.yml │ ├── create-release-pr.yml │ ├── main.yml │ ├── publish-main-site.yml │ ├── publish-pr-site.yml │ ├── publish-release.yml │ └── publish-site.yml ├── .gitignore ├── .nvmrc ├── .prettierrc.js ├── .swcrc ├── .yarn ├── plugins │ └── @yarnpkg │ │ ├── plugin-allow-scripts.cjs │ │ └── plugin-constraints.cjs └── releases │ └── yarn-3.2.1.cjs ├── .yarnrc.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── constraints.pro ├── jest.config.js ├── jest.environment.js ├── package.json ├── scripts ├── get.sh └── prepack.sh ├── src ├── App.test.tsx ├── App.tsx ├── assets │ ├── favicon.svg │ ├── file-mock.ts │ ├── fonts │ │ ├── EuclidCircularB-Bold.woff2 │ │ ├── EuclidCircularB-Regular.woff2 │ │ ├── IBMPlexMono-Regular.woff2 │ │ └── fonts.css │ ├── icons │ │ ├── alert.svg │ │ ├── arrow-down.svg │ │ ├── arrow-right.svg │ │ ├── arrow-top-right.svg │ │ ├── computer.svg │ │ ├── configuration.svg │ │ ├── copied.svg │ │ ├── copy.svg │ │ ├── copyable.svg │ │ ├── cronjob.svg │ │ ├── cross.svg │ │ ├── dark-arrow-top-right.svg │ │ ├── divider.svg │ │ ├── dot.svg │ │ ├── drag.svg │ │ ├── error-triangle.svg │ │ ├── github.svg │ │ ├── heading.svg │ │ ├── insights.svg │ │ ├── json-rpc.svg │ │ ├── manifest.svg │ │ ├── moon.svg │ │ ├── panel.svg │ │ ├── play-error.svg │ │ ├── play-muted.svg │ │ ├── play-success.svg │ │ ├── play.svg │ │ ├── snap.svg │ │ ├── text-bubble.svg │ │ ├── text.svg │ │ └── ui.svg │ └── logo.svg ├── components │ ├── Author.test.tsx │ ├── Author.tsx │ ├── Delineator.tsx │ ├── Editor.tsx │ ├── Icon.test.tsx │ ├── Icon.tsx │ ├── Link.test.tsx │ ├── Link.tsx │ ├── Logo.test.tsx │ ├── Logo.tsx │ ├── Prefill.test.tsx │ ├── Prefill.tsx │ ├── Root.tsx │ ├── SnapIcon.test.tsx │ ├── SnapIcon.tsx │ ├── Window.test.tsx │ ├── Window.tsx │ ├── dialogs │ │ ├── AlertDialog.test.tsx │ │ ├── AlertDialog.tsx │ │ ├── ConfirmationDialog.test.tsx │ │ ├── ConfirmationDialog.tsx │ │ ├── PromptDialog.test.tsx │ │ ├── PromptDialog.tsx │ │ └── index.ts │ └── index.ts ├── features │ ├── builder │ │ ├── Builder.tsx │ │ ├── components │ │ │ ├── BaseNode.tsx │ │ │ ├── EditableNode.tsx │ │ │ ├── Node.tsx │ │ │ ├── NodeRenderer.tsx │ │ │ ├── NodeTree.tsx │ │ │ ├── Start.tsx │ │ │ ├── TemplateComponent.tsx │ │ │ ├── TemplateComponentList.tsx │ │ │ └── index.ts │ │ ├── index.ts │ │ ├── utils.test.ts │ │ └── utils.ts │ ├── configuration │ │ ├── Configuration.test.tsx │ │ ├── Configuration.tsx │ │ ├── index.ts │ │ ├── slice.test.ts │ │ └── slice.ts │ ├── console │ │ ├── Console.tsx │ │ ├── ConsoleContent.tsx │ │ ├── index.ts │ │ └── slice.ts │ ├── handlers │ │ ├── components │ │ │ ├── Handler.test.tsx │ │ │ ├── Handler.tsx │ │ │ ├── History.test.tsx │ │ │ ├── History.tsx │ │ │ ├── HistoryItem.test.tsx │ │ │ ├── HistoryItem.tsx │ │ │ ├── PlayButton.tsx │ │ │ ├── ResetTab.tsx │ │ │ ├── ResetUserInterfaceTab.tsx │ │ │ ├── Response.test.tsx │ │ │ ├── Response.tsx │ │ │ ├── UserInterface.test.tsx │ │ │ ├── UserInterface.tsx │ │ │ └── index.ts │ │ ├── cronjobs │ │ │ ├── Cronjobs.test.tsx │ │ │ ├── Cronjobs.tsx │ │ │ ├── components │ │ │ │ ├── CronjobPrefill.test.tsx │ │ │ │ ├── CronjobPrefill.tsx │ │ │ │ ├── CronjobPrefills.test.tsx │ │ │ │ ├── CronjobPrefills.tsx │ │ │ │ ├── Request.test.tsx │ │ │ │ ├── Request.tsx │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── slice.test.ts │ │ │ └── slice.ts │ │ ├── index.ts │ │ ├── json-rpc │ │ │ ├── JsonRpc.test.tsx │ │ │ ├── JsonRpc.tsx │ │ │ ├── components │ │ │ │ ├── Request.tsx │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── schema.ts │ │ │ ├── slice.test.ts │ │ │ └── slice.ts │ │ ├── slice.ts │ │ └── transactions │ │ │ ├── Transactions.test.tsx │ │ │ ├── Transactions.tsx │ │ │ ├── components │ │ │ ├── Request.tsx │ │ │ ├── TransactionPrefill.test.tsx │ │ │ ├── TransactionPrefill.tsx │ │ │ ├── TransactionPrefills.test.tsx │ │ │ ├── TransactionPrefills.tsx │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── presets.ts │ │ │ ├── slice.test.ts │ │ │ ├── slice.ts │ │ │ ├── utils.test.ts │ │ │ └── utils.ts │ ├── index.ts │ ├── layout │ │ ├── Layout.tsx │ │ ├── components │ │ │ ├── Header.test.tsx │ │ │ ├── Header.tsx │ │ │ ├── Sidebar.test.tsx │ │ │ ├── Sidebar.tsx │ │ │ └── index.ts │ │ └── index.ts │ ├── manifest │ │ ├── Manifest.test.tsx │ │ ├── Manifest.tsx │ │ ├── components │ │ │ ├── Item.test.tsx │ │ │ ├── Item.tsx │ │ │ ├── Validation.test.tsx │ │ │ ├── Validation.tsx │ │ │ └── index.ts │ │ ├── index.ts │ │ ├── sagas.test.ts │ │ ├── sagas.ts │ │ ├── slice.test.ts │ │ ├── slice.ts │ │ └── validators.ts │ ├── navigation │ │ ├── Navigation.test.tsx │ │ ├── Navigation.tsx │ │ ├── components │ │ │ ├── Bottom.test.tsx │ │ │ ├── Bottom.tsx │ │ │ ├── Item.test.tsx │ │ │ ├── Item.tsx │ │ │ ├── ManifestStatusIndicator.tsx │ │ │ └── index.ts │ │ ├── index.ts │ │ └── items.ts │ ├── notifications │ │ ├── Notifications.test.tsx │ │ ├── Notifications.tsx │ │ ├── index.ts │ │ ├── slice.test.ts │ │ └── slice.ts │ ├── polling │ │ ├── index.ts │ │ ├── sagas.test.ts │ │ └── sagas.ts │ ├── renderer │ │ ├── Renderer.tsx │ │ ├── components │ │ │ ├── Copyable.tsx │ │ │ ├── Divider.tsx │ │ │ ├── Heading.tsx │ │ │ ├── Panel.tsx │ │ │ ├── Spinner.tsx │ │ │ ├── Text.tsx │ │ │ └── index.ts │ │ └── index.ts │ ├── simulation │ │ ├── hooks.test.ts │ │ ├── hooks.ts │ │ ├── index.ts │ │ ├── middleware.test.ts │ │ ├── middleware.ts │ │ ├── sagas.test.ts │ │ ├── sagas.ts │ │ ├── slice.test.ts │ │ ├── slice.ts │ │ ├── snap-permissions.ts │ │ └── test │ │ │ ├── mockExecutionService.ts │ │ │ ├── mockManifest.ts │ │ │ └── mockSnap.ts │ └── status │ │ ├── StatusIndicator.test.tsx │ │ ├── StatusIndicator.tsx │ │ └── index.ts ├── hooks │ ├── index.ts │ ├── useDispatch.ts │ ├── useHandler.tsx │ └── useSelector.ts ├── index.html ├── index.tsx ├── routes.tsx ├── store │ ├── index.ts │ ├── middleware.ts │ ├── reducer.ts │ ├── sagas.ts │ └── store.ts ├── theme.ts ├── types.d.ts └── utils │ ├── index.ts │ └── render.tsx ├── tsconfig.build.json ├── tsconfig.json ├── webpack.config.ts └── yarn.lock /.depcheckrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignores": [ 3 | "@lavamoat/allow-scripts", 4 | "@lavamoat/preinstall-always-fail", 5 | "@metamask/auto-changelog", 6 | "@swc/core", 7 | "@swc/jest", 8 | "@types/*", 9 | "css-loader", 10 | "lint-staged", 11 | "prettier-plugin-packagejson", 12 | "simple-git-hooks", 13 | "style-loader", 14 | "swc-loader", 15 | "ts-node", 16 | "typedoc", 17 | "webpack-cli", 18 | "process", 19 | "assert" 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | 4 | extends: ['@metamask/eslint-config'], 5 | 6 | settings: { 7 | 'import/extensions': ['.ts', '.tsx'], 8 | }, 9 | 10 | overrides: [ 11 | { 12 | files: ['*.ts', '*.tsx'], 13 | extends: [ 14 | '@metamask/eslint-config-typescript', 15 | '@metamask/eslint-config-browser', 16 | 'plugin:react/recommended', 17 | 'plugin:react/jsx-runtime', 18 | 'plugin:react-hooks/recommended', 19 | ], 20 | rules: { 21 | '@typescript-eslint/no-shadow': [ 22 | 'error', 23 | { 24 | allow: ['Text'], 25 | }, 26 | ], 27 | 28 | 'react/display-name': 'off', 29 | 'react/prop-types': 'off', 30 | }, 31 | settings: { 32 | react: { 33 | version: 'detect', 34 | }, 35 | }, 36 | }, 37 | 38 | { 39 | files: ['*.js'], 40 | parserOptions: { 41 | sourceType: 'script', 42 | }, 43 | extends: ['@metamask/eslint-config-nodejs'], 44 | }, 45 | 46 | { 47 | files: ['*.test.ts', '*.test.js'], 48 | extends: [ 49 | '@metamask/eslint-config-jest', 50 | '@metamask/eslint-config-nodejs', 51 | ], 52 | rules: { 53 | 'no-restricted-globals': 'off', 54 | 'jest/expect-expect': [ 55 | 'error', 56 | { 57 | assertFunctionNames: ['expect', 'expectSaga'], 58 | }, 59 | ], 60 | }, 61 | }, 62 | 63 | { 64 | files: ['webpack.config.ts'], 65 | extends: ['@metamask/eslint-config-nodejs'], 66 | }, 67 | ], 68 | 69 | ignorePatterns: ['!.eslintrc.js', '!.prettierrc.js', 'dist/', '.yarn/'], 70 | }; 71 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | 3 | yarn.lock linguist-generated=false 4 | 5 | # yarn v3 6 | # See: https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored 7 | /.yarn/releases/** binary 8 | /.yarn/plugins/** binary 9 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Lines starting with '#' are comments. 2 | # Each line is a file pattern followed by one or more owners. 3 | 4 | * @FrederikBolding @Mrtenz 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Please see the documentation for all configuration options: 2 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 3 | 4 | version: 2 5 | updates: 6 | - package-ecosystem: 'npm' 7 | directory: '/' 8 | schedule: 9 | interval: 'daily' 10 | time: '06:00' 11 | allow: 12 | - dependency-name: '@metamask/*' 13 | target-branch: 'main' 14 | versioning-strategy: 'increase-if-necessary' 15 | open-pull-requests-limit: 10 16 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /.github/workflows/create-release-pr.yml: -------------------------------------------------------------------------------- 1 | name: Create Release Pull Request 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | base-branch: 7 | description: 'The base branch for git operations and the pull request.' 8 | default: 'main' 9 | required: true 10 | release-type: 11 | description: 'A SemVer version diff, i.e. major, minor, or patch. Mutually exclusive with "release-version".' 12 | required: false 13 | release-version: 14 | description: 'A specific version to bump to. Mutually exclusive with "release-type".' 15 | required: false 16 | 17 | jobs: 18 | create-release-pr: 19 | runs-on: ubuntu-latest 20 | permissions: 21 | contents: write 22 | pull-requests: write 23 | steps: 24 | - uses: actions/checkout@v3 25 | with: 26 | # This is to guarantee that the most recent tag is fetched. 27 | # This can be configured to a more reasonable value by consumers. 28 | fetch-depth: 0 29 | # We check out the specified branch, which will be used as the base 30 | # branch for all git operations and the release PR. 31 | ref: ${{ github.event.inputs.base-branch }} 32 | - name: Setup Node.js 33 | uses: actions/setup-node@v3 34 | with: 35 | node-version-file: '.nvmrc' 36 | - uses: MetaMask/action-create-release-pr@v1 37 | env: 38 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 39 | with: 40 | release-type: ${{ github.event.inputs.release-type }} 41 | release-version: ${{ github.event.inputs.release-version }} 42 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Main 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | 8 | jobs: 9 | check-workflows: 10 | name: Check workflows 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - name: Download actionlint 15 | id: download-actionlint 16 | run: bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/7fdc9630cc360ea1a469eed64ac6d78caeda1234/scripts/download-actionlint.bash) 1.6.23 17 | shell: bash 18 | - name: Check workflow files 19 | run: ${{ steps.download-actionlint.outputs.executable }} -color 20 | shell: bash 21 | 22 | build-lint-test: 23 | name: Build, lint, and test 24 | uses: ./.github/workflows/build-lint-test.yml 25 | 26 | all-jobs-completed: 27 | name: All jobs completed 28 | runs-on: ubuntu-latest 29 | needs: 30 | - check-workflows 31 | - build-lint-test 32 | outputs: 33 | PASSED: ${{ steps.set-output.outputs.PASSED }} 34 | steps: 35 | - name: Set PASSED output 36 | id: set-output 37 | run: echo "PASSED=true" >> "$GITHUB_OUTPUT" 38 | 39 | all-jobs-pass: 40 | name: All jobs pass 41 | if: ${{ always() }} 42 | runs-on: ubuntu-latest 43 | needs: all-jobs-completed 44 | steps: 45 | - name: Check that all jobs have passed 46 | run: | 47 | passed="${{ needs.all-jobs-completed.outputs.PASSED }}" 48 | if [[ $passed != "true" ]]; then 49 | exit 1 50 | fi 51 | 52 | is-release: 53 | # Filtering by `push` events ensures that we only release from the `main` branch, which is a 54 | # requirement for our npm publishing environment. 55 | # The commit author should always be 'github-actions' for releases created by the 56 | # 'create-release-pr' workflow, so we filter by that as well to prevent accidentally 57 | # triggering a release. 58 | if: github.event_name == 'push' && startsWith(github.event.head_commit.author.name, 'github-actions') 59 | needs: all-jobs-pass 60 | outputs: 61 | IS_RELEASE: ${{ steps.is-release.outputs.IS_RELEASE }} 62 | runs-on: ubuntu-latest 63 | steps: 64 | - uses: MetaMask/action-is-release@v1 65 | id: is-release 66 | 67 | publish-release: 68 | needs: is-release 69 | if: needs.is-release.outputs.IS_RELEASE == 'true' 70 | name: Publish release 71 | permissions: 72 | contents: write 73 | uses: ./.github/workflows/publish-release.yml 74 | secrets: 75 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 76 | -------------------------------------------------------------------------------- /.github/workflows/publish-main-site.yml: -------------------------------------------------------------------------------- 1 | name: Publish main branch site to GitHub Pages 2 | 3 | on: 4 | push: 5 | branches: main 6 | 7 | jobs: 8 | publish-to-gh-pages: 9 | name: Publish site to `staging` directory of `gh-pages` branch 10 | permissions: 11 | contents: write 12 | uses: ./.github/workflows/publish-site.yml 13 | with: 14 | destination_dir: staging 15 | -------------------------------------------------------------------------------- /.github/workflows/publish-pr-site.yml: -------------------------------------------------------------------------------- 1 | name: Publish pull request branch site to GitHub Pages 2 | 3 | concurrency: 4 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 5 | cancel-in-progress: true 6 | 7 | on: 8 | pull_request: 9 | types: 10 | - opened 11 | - synchronize 12 | 13 | jobs: 14 | publish-to-gh-pages: 15 | name: Publish site to `pr/${{ github.event.pull_request.number }}` directory of `gh-pages` branch 16 | permissions: 17 | contents: write 18 | uses: ./.github/workflows/publish-site.yml 19 | with: 20 | destination_dir: pr/${{ github.event.pull_request.number }} 21 | 22 | comment-on-pr: 23 | name: Comment on pull request with link to site 24 | runs-on: ubuntu-latest 25 | needs: publish-to-gh-pages 26 | permissions: 27 | contents: read 28 | pull-requests: write 29 | steps: 30 | - name: Comment on pull request 31 | uses: actions/github-script@v6 32 | with: 33 | github-token: ${{ secrets.GITHUB_TOKEN }} 34 | script: | 35 | github.rest.issues.createComment({ 36 | owner: context.repo.owner, 37 | repo: context.repo.repo, 38 | issue_number: context.issue.number, 39 | body: `Preview site: https://metamask.github.io/snaps-simulator/pr/${context.issue.number}/`, 40 | }); 41 | -------------------------------------------------------------------------------- /.github/workflows/publish-release.yml: -------------------------------------------------------------------------------- 1 | name: Publish Release 2 | 3 | on: 4 | workflow_call: 5 | secrets: 6 | NPM_TOKEN: 7 | required: true 8 | 9 | jobs: 10 | publish-release: 11 | permissions: 12 | contents: write 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | with: 17 | ref: ${{ github.sha }} 18 | - name: Setup Node.js 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version-file: '.nvmrc' 22 | - uses: MetaMask/action-publish-release@v2 23 | env: 24 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 25 | 26 | publish-release-to-gh-pages: 27 | name: Publish site to `gh-pages` 28 | permissions: 29 | contents: write 30 | uses: ./.github/workflows/publish-site.yml 31 | with: 32 | destination_dir: . 33 | -------------------------------------------------------------------------------- /.github/workflows/publish-site.yml: -------------------------------------------------------------------------------- 1 | name: Publish site to GitHub Pages 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | destination_dir: 7 | required: true 8 | type: string 9 | 10 | jobs: 11 | publish-site-to-gh-pages: 12 | name: Publish site to GitHub Pages 13 | runs-on: ubuntu-latest 14 | environment: github-pages 15 | permissions: 16 | contents: write 17 | steps: 18 | - name: Ensure `destination_dir` is not empty 19 | if: ${{ inputs.destination_dir == '' }} 20 | run: exit 1 21 | - name: Checkout the repository 22 | uses: actions/checkout@v3 23 | - name: Use Node.js 24 | uses: actions/setup-node@v3 25 | with: 26 | node-version-file: '.nvmrc' 27 | cache: 'yarn' 28 | - name: Install npm dependencies 29 | run: yarn --immutable 30 | - name: Run build script 31 | run: yarn build 32 | - name: Deploy to `${{ inputs.destination_dir }}` directory of `gh-pages` branch 33 | uses: peaceiris/actions-gh-pages@de7ea6f8efb354206b205ef54722213d99067935 34 | with: 35 | github_token: ${{ secrets.GITHUB_TOKEN }} 36 | publish_dir: ./dist 37 | destination_dir: ${{ inputs.destination_dir }} 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | dist/ 3 | coverage/ 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | 13 | # Diagnostic reports (https://nodejs.org/api/report.html) 14 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 15 | 16 | # Runtime data 17 | pids 18 | *.pid 19 | *.seed 20 | *.pid.lock 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | 38 | # TypeScript cache 39 | *.tsbuildinfo 40 | 41 | # Optional npm cache directory 42 | .npm 43 | 44 | # Optional eslint cache 45 | .eslintcache 46 | 47 | # Microbundle cache 48 | .rpt2_cache/ 49 | .rts2_cache_cjs/ 50 | .rts2_cache_es/ 51 | .rts2_cache_umd/ 52 | 53 | # Optional REPL history 54 | .node_repl_history 55 | 56 | # Output of 'npm pack' 57 | *.tgz 58 | 59 | # Yarn Integrity file 60 | .yarn-integrity 61 | 62 | # dotenv environment variables file 63 | .env 64 | .env.test 65 | 66 | # Stores VSCode versions used for testing VSCode extensions 67 | .vscode-test 68 | 69 | # yarn v3 (w/o zero-install) 70 | # See: https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored 71 | .pnp.* 72 | .yarn/* 73 | !.yarn/patches 74 | !.yarn/plugins 75 | !.yarn/releases 76 | !.yarn/sdks 77 | !.yarn/versions 78 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v16 2 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | // All of these are defaults except singleQuote, but we specify them 2 | // for explicitness 3 | module.exports = { 4 | quoteProps: 'as-needed', 5 | singleQuote: true, 6 | tabWidth: 2, 7 | trailingComma: 'all', 8 | }; 9 | -------------------------------------------------------------------------------- /.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/swcrc", 3 | "jsc": { 4 | "parser": { 5 | "syntax": "typescript", 6 | "tsx": true 7 | }, 8 | "transform": { 9 | "react": { 10 | "runtime": "automatic", 11 | "refresh": true 12 | } 13 | }, 14 | "target": "es2020" 15 | }, 16 | "module": { 17 | "type": "es6" 18 | }, 19 | "minify": true 20 | } 21 | -------------------------------------------------------------------------------- /.yarn/plugins/@yarnpkg/plugin-allow-scripts.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | //prettier-ignore 3 | module.exports = { 4 | name: "@yarnpkg/plugin-allow-scripts", 5 | factory: function (require) { 6 | var plugin=(()=>{var a=Object.create,l=Object.defineProperty;var i=Object.getOwnPropertyDescriptor;var s=Object.getOwnPropertyNames;var p=Object.getPrototypeOf,c=Object.prototype.hasOwnProperty;var u=e=>l(e,"__esModule",{value:!0});var f=e=>{if(typeof require!="undefined")return require(e);throw new Error('Dynamic require of "'+e+'" is not supported')};var g=(e,o)=>{for(var r in o)l(e,r,{get:o[r],enumerable:!0})},m=(e,o,r)=>{if(o&&typeof o=="object"||typeof o=="function")for(let t of s(o))!c.call(e,t)&&t!=="default"&&l(e,t,{get:()=>o[t],enumerable:!(r=i(o,t))||r.enumerable});return e},x=e=>m(u(l(e!=null?a(p(e)):{},"default",e&&e.__esModule&&"default"in e?{get:()=>e.default,enumerable:!0}:{value:e,enumerable:!0})),e);var k={};g(k,{default:()=>d});var n=x(f("@yarnpkg/shell")),y={hooks:{afterAllInstalled:async()=>{let e=await(0,n.execute)("yarn run allow-scripts");e!==0&&process.exit(e)}}},d=y;return k;})(); 7 | return plugin; 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | enableScripts: false 2 | 3 | enableTelemetry: 0 4 | 5 | logFilters: 6 | - code: YN0004 7 | level: discard 8 | 9 | nodeLinker: node-modules 10 | 11 | plugins: 12 | - path: .yarn/plugins/@yarnpkg/plugin-allow-scripts.cjs 13 | spec: 'https://raw.githubusercontent.com/LavaMoat/LavaMoat/main/packages/yarn-plugin-allow-scripts/bundles/@yarnpkg/plugin-allow-scripts.js' 14 | - path: .yarn/plugins/@yarnpkg/plugin-constraints.cjs 15 | spec: '@yarnpkg/plugin-constraints' 16 | 17 | yarnPath: .yarn/releases/yarn-3.2.1.cjs 18 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | 9 | [Unreleased]: https://github.com/MetaMask/snaps-simulator/ 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 MetaMask 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | -------------------------------------------------------------------------------- /jest.environment.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | const { TestEnvironment } = require('jest-environment-jsdom'); 3 | 4 | // Custom test environment copied from https://github.com/jsdom/jsdom/issues/2524 5 | // in order to add TextEncoder to jsdom. TextEncoder is expected by jose. 6 | 7 | module.exports = class CustomTestEnvironment extends TestEnvironment { 8 | async setup() { 9 | await super.setup(); 10 | if (typeof this.global.TextEncoder === 'undefined') { 11 | const { TextEncoder, TextDecoder } = require('util'); 12 | this.global.TextEncoder = TextEncoder; 13 | this.global.TextDecoder = TextDecoder; 14 | this.global.ArrayBuffer = ArrayBuffer; 15 | this.global.Uint8Array = Uint8Array; 16 | } 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /scripts/get.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -x 4 | set -e 5 | set -u 6 | set -o pipefail 7 | 8 | KEY="${1}" 9 | OUTPUT="${2}" 10 | 11 | if [[ -z $KEY ]]; then 12 | echo "Error: KEY not specified." 13 | exit 1 14 | fi 15 | 16 | if [[ -z $OUTPUT ]]; then 17 | echo "Error: OUTPUT not specified." 18 | exit 1 19 | fi 20 | 21 | echo "$OUTPUT=$(jq --raw-output "$KEY" package.json)" >> "$GITHUB_OUTPUT" 22 | -------------------------------------------------------------------------------- /scripts/prepack.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -x 4 | set -e 5 | set -o pipefail 6 | 7 | if [[ -n $SKIP_PREPACK ]]; then 8 | echo "Notice: skipping prepack." 9 | exit 0 10 | fi 11 | 12 | yarn build:clean 13 | -------------------------------------------------------------------------------- /src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import fetchMock from 'jest-fetch-mock'; 3 | 4 | import { App } from './App'; 5 | import { Root } from './components'; 6 | import { createStore } from './store'; 7 | 8 | describe('App', () => { 9 | it('renders', () => { 10 | // This polyfills Request which seems to be missing in the Jest testing environment. 11 | fetchMock.enableMocks(); 12 | expect(() => 13 | render( 14 | 15 | 16 | , 17 | ), 18 | ).not.toThrow(); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent, useEffect } from 'react'; 2 | import { RouterProvider } from 'react-router-dom'; 3 | 4 | import { setSnapUrl } from './features'; 5 | import { useDispatch } from './hooks'; 6 | import { router } from './routes'; 7 | 8 | export const App: FunctionComponent = () => { 9 | const dispatch = useDispatch(); 10 | useEffect(() => { 11 | // Dispatch to start polling the default URL 12 | dispatch(setSnapUrl('http://localhost:8080')); 13 | }, [dispatch]); 14 | return ; 15 | }; 16 | -------------------------------------------------------------------------------- /src/assets/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/assets/file-mock.ts: -------------------------------------------------------------------------------- 1 | const fileMock = 'test-file-stub'; 2 | export default fileMock; 3 | -------------------------------------------------------------------------------- /src/assets/fonts/EuclidCircularB-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MetaMask/snaps-simulator/f740a269915de0c6635778a4aa5db39d64d92c4a/src/assets/fonts/EuclidCircularB-Bold.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/EuclidCircularB-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MetaMask/snaps-simulator/f740a269915de0c6635778a4aa5db39d64d92c4a/src/assets/fonts/EuclidCircularB-Regular.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/IBMPlexMono-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MetaMask/snaps-simulator/f740a269915de0c6635778a4aa5db39d64d92c4a/src/assets/fonts/IBMPlexMono-Regular.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/fonts.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Euclid Circular B'; 3 | font-style: normal; 4 | font-weight: 400; 5 | src: url('./EuclidCircularB-Regular.woff2') format('woff2'); 6 | } 7 | 8 | @font-face { 9 | font-family: 'Euclid Circular B'; 10 | font-style: normal; 11 | font-weight: 700; 12 | src: url('./EuclidCircularB-Bold.woff2') format('woff2'); 13 | } 14 | 15 | @font-face { 16 | font-family: 'IBM Plex Mono'; 17 | font-style: normal; 18 | font-weight: 400; 19 | src: url('./IBMPlexMono-Regular.woff2') format('woff2'); 20 | } 21 | -------------------------------------------------------------------------------- /src/assets/icons/alert.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/assets/icons/arrow-down.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/arrow-right.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/arrow-top-right.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/computer.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/configuration.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/copied.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/assets/icons/copy.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/assets/icons/copyable.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/assets/icons/cronjob.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/assets/icons/cross.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/assets/icons/dark-arrow-top-right.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/divider.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/dot.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/drag.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/error-triangle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/github.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/heading.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/insights.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/assets/icons/json-rpc.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/assets/icons/manifest.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/assets/icons/moon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/panel.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/play-error.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/assets/icons/play-muted.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/assets/icons/play-success.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/assets/icons/play.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/assets/icons/snap.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/assets/icons/text-bubble.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/assets/icons/text.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/components/Author.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '../utils'; 2 | import { Author } from './Author'; 3 | 4 | describe('Author', () => { 5 | it('renders', () => { 6 | expect(() => 7 | render( 8 | , 9 | ), 10 | ).not.toThrow(); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /src/components/Author.tsx: -------------------------------------------------------------------------------- 1 | import { Flex, Text } from '@chakra-ui/react'; 2 | import { FunctionComponent } from 'react'; 3 | 4 | import { SnapIcon } from './SnapIcon'; 5 | 6 | type AuthorProps = { 7 | snapName: string; 8 | snapId: string; 9 | }; 10 | 11 | export const Author: FunctionComponent = ({ 12 | snapName, 13 | snapId, 14 | }) => ( 15 | 25 | 26 | 27 | 28 | {snapName} 29 | 30 | 36 | {snapId} 37 | 38 | 39 | 40 | ); 41 | -------------------------------------------------------------------------------- /src/components/Delineator.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Flex, Text } from '@chakra-ui/react'; 2 | import { FunctionComponent, ReactNode } from 'react'; 3 | 4 | import { Icon } from './Icon'; 5 | 6 | export type DelineatorProps = { 7 | snapName: string; 8 | children: ReactNode; 9 | }; 10 | 11 | export const Delineator: FunctionComponent = ({ 12 | snapName, 13 | children, 14 | }) => ( 15 | 16 | 23 | 24 | 25 | Content from {snapName} 26 | 27 | 28 | {children} 29 | 30 | ); 31 | -------------------------------------------------------------------------------- /src/components/Editor.tsx: -------------------------------------------------------------------------------- 1 | import { Box, BoxProps } from '@chakra-ui/react'; 2 | import { FunctionComponent } from 'react'; 3 | import MonacoEditor, { monaco, MonacoEditorProps } from 'react-monaco-editor'; 4 | 5 | import { 6 | JSON_RPC_SCHEMA, 7 | JSON_RPC_SCHEMA_URL, 8 | SAMPLE_JSON_RPC_REQUEST, 9 | } from '../features/handlers/json-rpc/schema'; 10 | 11 | export type EditorProps = MonacoEditorProps & BoxProps; 12 | 13 | /** 14 | * Editor component. This uses Monaco Editor to provide a JSON editor. 15 | * 16 | * @param props - The props. 17 | * @param props.border - The border. 18 | * @param props.borderRadius - The border radius. 19 | * @returns The editor component. 20 | */ 21 | export const Editor: FunctionComponent = ({ 22 | border = '1px solid', 23 | ...props 24 | }) => { 25 | const handleMount = (editor: typeof monaco) => { 26 | editor.languages.json?.jsonDefaults.setDiagnosticsOptions({ 27 | validate: true, 28 | schemas: [ 29 | { 30 | uri: JSON_RPC_SCHEMA_URL, 31 | fileMatch: ['*'], 32 | schema: JSON_RPC_SCHEMA, 33 | }, 34 | ], 35 | }); 36 | }; 37 | 38 | return ( 39 | 47 | 74 | 75 | ); 76 | }; 77 | -------------------------------------------------------------------------------- /src/components/Icon.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '../utils'; 2 | import { Icon } from './Icon'; 3 | 4 | describe('Icon', () => { 5 | it('renders', () => { 6 | expect(() => render()).not.toThrow(); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /src/components/Link.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '../utils'; 2 | import { Link } from './Link'; 3 | 4 | describe('Link', () => { 5 | it('renders', () => { 6 | expect(() => render()).not.toThrow(); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /src/components/Link.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | LinkProps as ChakraLinkProps, 3 | Link as ChakraLink, 4 | } from '@chakra-ui/react'; 5 | import { FunctionComponent } from 'react'; 6 | import { 7 | Link as RouterLink, 8 | LinkProps as RouterLinkProps, 9 | } from 'react-router-dom'; 10 | 11 | type LinkProps = { 12 | to: string; 13 | isExternal?: boolean; 14 | } & ChakraLinkProps & 15 | RouterLinkProps; 16 | 17 | /** 18 | * A link component for internal links. This component is a wrapper around 19 | * Chakra's Link component and React Router's Link component, to use both 20 | * together. 21 | * 22 | * @param props - The props of the component. 23 | * @param props.to - The path to link to. 24 | * @param props.children - The children of the component. 25 | * @param props.isExternal - Whether the link is external or not. 26 | * @returns The link component. 27 | */ 28 | export const Link: FunctionComponent = ({ 29 | to, 30 | isExternal = false, 31 | children, 32 | ...props 33 | }) => { 34 | if (isExternal) { 35 | return ( 36 | 37 | {children} 38 | 39 | ); 40 | } 41 | 42 | return ( 43 | 44 | {children} 45 | 46 | ); 47 | }; 48 | -------------------------------------------------------------------------------- /src/components/Logo.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '../utils'; 2 | import { Logo } from './Logo'; 3 | 4 | describe('Logo', () => { 5 | it('renders', () => { 6 | expect(() => render()).not.toThrow(); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /src/components/Logo.tsx: -------------------------------------------------------------------------------- 1 | import { Image } from '@chakra-ui/react'; 2 | import { FunctionComponent } from 'react'; 3 | 4 | import logo from '../assets/logo.svg'; 5 | 6 | /** 7 | * Render the MetaMask logo. 8 | * 9 | * @returns A React component. 10 | */ 11 | export const Logo: FunctionComponent = () => ( 12 | MetaMask 13 | ); 14 | -------------------------------------------------------------------------------- /src/components/Prefill.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '../utils'; 2 | import { Prefill } from './Prefill'; 3 | 4 | describe('Prefill', () => { 5 | it('renders', () => { 6 | expect(() => render(Prefill)).not.toThrow(); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /src/components/Prefill.tsx: -------------------------------------------------------------------------------- 1 | import { Tag, TagProps } from '@chakra-ui/react'; 2 | import { forwardRef, ReactNode } from 'react'; 3 | 4 | import { Icon, IconName } from './Icon'; 5 | 6 | type PrefillProps = TagProps & { 7 | icon?: IconName; 8 | iconLocation?: 'left' | 'right'; 9 | children: ReactNode; 10 | }; 11 | 12 | export const Prefill = forwardRef( 13 | ( 14 | { 15 | children, 16 | icon = 'darkArrowTopRightIcon', 17 | iconLocation = 'right', 18 | ...props 19 | }, 20 | ref, 21 | ) => ( 22 | 35 | {iconLocation === 'left' && ( 36 | 37 | )} 38 | {children} 39 | {iconLocation === 'right' && ( 40 | 41 | )} 42 | 43 | ), 44 | ); 45 | -------------------------------------------------------------------------------- /src/components/Root.tsx: -------------------------------------------------------------------------------- 1 | import { ChakraProvider } from '@chakra-ui/react'; 2 | import { getBackendOptions, MultiBackend } from '@minoru/react-dnd-treeview'; 3 | import { FunctionComponent, ReactElement, StrictMode } from 'react'; 4 | import { DndProvider } from 'react-dnd'; 5 | import { Provider } from 'react-redux'; 6 | 7 | import { Notifications } from '../features'; 8 | import type { createStore } from '../store'; 9 | import { theme } from '../theme'; 10 | 11 | export type RootProps = { 12 | store: ReturnType; 13 | children: ReactElement; 14 | }; 15 | 16 | /** 17 | * Render the root component. This is the top-level component that wraps the 18 | * entire application. 19 | * 20 | * @param props - The props. 21 | * @param props.store - The Redux store. 22 | * @param props.children - The children to render. 23 | * @returns The root component. 24 | */ 25 | export const Root: FunctionComponent = ({ store, children }) => ( 26 | 27 | 28 | 29 | 30 | 31 | {children} 32 | 33 | 34 | 35 | 36 | ); 37 | -------------------------------------------------------------------------------- /src/components/SnapIcon.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '../utils'; 2 | import { SnapIcon } from './SnapIcon'; 3 | 4 | describe('SnapIcon', () => { 5 | it('renders', () => { 6 | expect(() => render()).not.toThrow(); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /src/components/SnapIcon.tsx: -------------------------------------------------------------------------------- 1 | import { Avatar, Box } from '@chakra-ui/react'; 2 | import { FunctionComponent } from 'react'; 3 | 4 | import { getIcon } from '../features'; 5 | import { useSelector } from '../hooks'; 6 | import { Icon } from './Icon'; 7 | 8 | export type SnapIconProps = { 9 | snapName: string; 10 | }; 11 | 12 | /** 13 | * A Snap icon, which renders the icon defined in the snap's manifest, or a 14 | * fallback icon if the snap doesn't define one. 15 | * 16 | * @param props - The props. 17 | * @param props.snapName - The name of the snap. 18 | * @returns The Snap icon component. 19 | */ 20 | export const SnapIcon: FunctionComponent = ({ snapName }) => { 21 | const snapIcon = useSelector(getIcon); 22 | 23 | const blob = 24 | snapIcon && new Blob([snapIcon.value], { type: 'image/svg+xml' }); 25 | const blobUrl = blob && URL.createObjectURL(blob); 26 | 27 | return ( 28 | 29 | 38 | 46 | 47 | ); 48 | }; 49 | -------------------------------------------------------------------------------- /src/components/Window.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '../utils'; 2 | import { Window } from './Window'; 3 | 4 | describe('Window', () => { 5 | it('renders', () => { 6 | expect(() => 7 | render( 8 | 9 | Foo 10 | , 11 | ), 12 | ).not.toThrow(); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /src/components/Window.tsx: -------------------------------------------------------------------------------- 1 | import { Flex } from '@chakra-ui/react'; 2 | import { FunctionComponent, ReactNode } from 'react'; 3 | 4 | import { Author } from './Author'; 5 | 6 | type WindowProps = { 7 | snapName: string; 8 | snapId: string; 9 | children: ReactNode; 10 | showAuthorship?: boolean; 11 | }; 12 | 13 | /** 14 | * A MetaMask-like window, with a snap authorship pill. 15 | * 16 | * @param props - The props. 17 | * @param props.snapName - The name of the snap. 18 | * @param props.snapId - The ID of the snap. 19 | * @param props.children - The children to render inside the window. 20 | * @param props.showAuthorship - Show the authorship component. 21 | * @returns The window component. 22 | */ 23 | export const Window: FunctionComponent = ({ 24 | snapName, 25 | snapId, 26 | children, 27 | showAuthorship = true, 28 | }) => ( 29 | 37 | {showAuthorship && } 38 | {children} 39 | 40 | ); 41 | -------------------------------------------------------------------------------- /src/components/dialogs/AlertDialog.test.tsx: -------------------------------------------------------------------------------- 1 | import { panel } from '@metamask/snaps-ui'; 2 | 3 | import { render } from '../../utils'; 4 | import { AlertDialog } from './AlertDialog'; 5 | 6 | describe('AlertDialog', () => { 7 | it('renders', () => { 8 | expect(() => 9 | render( 10 | , 15 | ), 16 | ).not.toThrow(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/components/dialogs/AlertDialog.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Button, Flex } from '@chakra-ui/react'; 2 | import { Component } from '@metamask/snaps-ui'; 3 | import { FunctionComponent } from 'react'; 4 | 5 | import { Renderer } from '../../features/renderer'; 6 | import { Delineator } from '../Delineator'; 7 | import { Window } from '../Window'; 8 | 9 | export type AlertDialogProps = { 10 | snapName: string; 11 | snapId: string; 12 | node: Component; 13 | onClose?: () => void; 14 | }; 15 | 16 | /** 17 | * Snap alert dialog. 18 | * 19 | * @param props - The component props. 20 | * @param props.snapName - The snap name. 21 | * @param props.snapId - The snap ID. 22 | * @param props.node - The component to render. 23 | * @param props.onClose - The close callback. 24 | * @returns The component. 25 | */ 26 | export const AlertDialog: FunctionComponent = ({ 27 | snapName, 28 | snapId, 29 | node, 30 | onClose, 31 | }) => ( 32 | 33 | 34 | 35 | 36 | 37 | 38 | 45 | 48 | 49 | 50 | ); 51 | -------------------------------------------------------------------------------- /src/components/dialogs/ConfirmationDialog.test.tsx: -------------------------------------------------------------------------------- 1 | import { panel } from '@metamask/snaps-ui'; 2 | 3 | import { render } from '../../utils'; 4 | import { ConfirmationDialog } from './ConfirmationDialog'; 5 | 6 | describe('ConfirmationDialog', () => { 7 | it('renders', () => { 8 | expect(() => 9 | render( 10 | , 15 | ), 16 | ).not.toThrow(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/components/dialogs/ConfirmationDialog.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Button, Flex } from '@chakra-ui/react'; 2 | import { Component } from '@metamask/snaps-ui'; 3 | import { FunctionComponent } from 'react'; 4 | 5 | import { Renderer } from '../../features/renderer'; 6 | import { Delineator } from '../Delineator'; 7 | import { Window } from '../Window'; 8 | 9 | export type ConfirmationDialogProps = { 10 | snapName: string; 11 | snapId: string; 12 | node: Component; 13 | onCancel?: () => void; 14 | onApprove?: () => void; 15 | }; 16 | 17 | /** 18 | * Snap confirmation dialog. 19 | * 20 | * @param props - The component props. 21 | * @param props.snapName - The snap name. 22 | * @param props.snapId - The snap ID. 23 | * @param props.node - The component to render. 24 | * @param props.onCancel - The cancel callback. 25 | * @param props.onApprove - The approve callback. 26 | * @returns The component. 27 | */ 28 | export const ConfirmationDialog: FunctionComponent = ({ 29 | snapName, 30 | snapId, 31 | node, 32 | onCancel, 33 | onApprove, 34 | }) => ( 35 | 36 | 37 | 38 | 39 | 40 | 41 | 48 | 51 | 54 | 55 | 56 | ); 57 | -------------------------------------------------------------------------------- /src/components/dialogs/PromptDialog.test.tsx: -------------------------------------------------------------------------------- 1 | import { panel } from '@metamask/snaps-ui'; 2 | 3 | import { render } from '../../utils'; 4 | import { PromptDialog } from './PromptDialog'; 5 | 6 | describe('PromptDialog', () => { 7 | it('renders', () => { 8 | expect(() => 9 | render( 10 | , 15 | ), 16 | ).not.toThrow(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/components/dialogs/PromptDialog.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Button, Flex, FormControl, Input } from '@chakra-ui/react'; 2 | import { Component } from '@metamask/snaps-ui'; 3 | import { FunctionComponent } from 'react'; 4 | import { useForm } from 'react-hook-form'; 5 | 6 | import { Renderer } from '../../features/renderer'; 7 | import { Delineator } from '../Delineator'; 8 | import { Window } from '../Window'; 9 | 10 | export type PromptDialogProps = { 11 | snapName: string; 12 | snapId: string; 13 | placeholder?: string; 14 | node: Component; 15 | onCancel?: () => void; 16 | onSubmit?: (value: string) => void; 17 | }; 18 | 19 | type PromptForm = { 20 | value: string; 21 | }; 22 | 23 | /** 24 | * Snap prompt dialog. 25 | * 26 | * @param props - The component props. 27 | * @param props.snapName - The snap name. 28 | * @param props.snapId - The snap ID. 29 | * @param props.placeholder - The placeholder text. 30 | * @param props.node - The component to render. 31 | * @param props.onCancel - The cancel callback. 32 | * @param props.onSubmit - The submit callback. The value is passed as the first 33 | * argument. 34 | * @returns The component. 35 | */ 36 | export const PromptDialog: FunctionComponent = ({ 37 | snapName, 38 | snapId, 39 | placeholder, 40 | node, 41 | onCancel, 42 | onSubmit, 43 | }) => { 44 | const { 45 | handleSubmit, 46 | register, 47 | formState: { errors }, 48 | } = useForm({ 49 | defaultValues: { 50 | value: '', 51 | }, 52 | }); 53 | 54 | const onFormSubmit = (data: PromptForm) => { 55 | onSubmit?.(data.value); 56 | }; 57 | 58 | return ( 59 | 60 | 61 | 62 | 63 | {/* eslint-disable-next-line @typescript-eslint/no-misused-promises */} 64 |
65 | 71 | 72 | 73 |
74 |
75 |
76 | 83 | 86 | 89 | 90 |
91 | ); 92 | }; 93 | -------------------------------------------------------------------------------- /src/components/dialogs/index.ts: -------------------------------------------------------------------------------- 1 | export * from './AlertDialog'; 2 | export * from './ConfirmationDialog'; 3 | export * from './PromptDialog'; 4 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | // Components that are used in multiple places in the app, such as components 2 | // that are used in multiple routes, can be exported here. Components that are 3 | // only used in one place, such as components that are only used in one route, 4 | // should be exported from their respective feature folder. 5 | 6 | export * from './dialogs'; 7 | export * from './Delineator'; 8 | export * from './Editor'; 9 | export * from './Icon'; 10 | export * from './Logo'; 11 | export * from './Prefill'; 12 | export * from './Root'; 13 | export * from './Link'; 14 | export * from './Window'; 15 | -------------------------------------------------------------------------------- /src/features/builder/components/BaseNode.tsx: -------------------------------------------------------------------------------- 1 | import { Flex, Text } from '@chakra-ui/react'; 2 | import { Component } from '@metamask/snaps-ui'; 3 | import { assert } from '@metamask/utils'; 4 | import { NodeModel } from '@minoru/react-dnd-treeview'; 5 | import { FunctionComponent, ReactNode } from 'react'; 6 | 7 | import { Icon, IconName } from '../../../components'; 8 | 9 | export type BaseNodeProps = { 10 | node: NodeModel; 11 | isDragging: boolean; 12 | children?: ReactNode; 13 | onClose?: ((node: NodeModel) => void) | undefined; 14 | }; 15 | 16 | export const BaseNode: FunctionComponent = ({ 17 | node, 18 | isDragging, 19 | children, 20 | onClose, 21 | }) => { 22 | assert(node.data?.type, 'Node must have a type.'); 23 | 24 | const handleClick = () => { 25 | onClose?.(node); 26 | }; 27 | 28 | return ( 29 | 1 ? 'move' : 'default'} 42 | > 43 | 44 | 52 | {node.data.type} 53 | 54 | {children} 55 | {node.id >= 2 && ( 56 | 64 | )} 65 | 66 | ); 67 | }; 68 | -------------------------------------------------------------------------------- /src/features/builder/components/EditableNode.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Input } from '@chakra-ui/react'; 2 | import { Component, Heading, Text } from '@metamask/snaps-ui'; 3 | import { assert } from '@metamask/utils'; 4 | import { NodeModel } from '@minoru/react-dnd-treeview'; 5 | import { ChangeEvent, FunctionComponent, useState } from 'react'; 6 | 7 | import { getNodeText } from '../utils'; 8 | import { BaseNode } from './BaseNode'; 9 | 10 | export type EditableComponent = Text | Heading; 11 | 12 | type EditableNodeProps = { 13 | node: NodeModel; 14 | depth: number; 15 | isDragging: boolean; 16 | onChange?: (node: NodeModel, value: string) => void; 17 | onClose?: ((node: NodeModel) => void) | undefined; 18 | }; 19 | 20 | /** 21 | * An editable node, which renders an editable component in the builder. 22 | * 23 | * @param props - The props of the component. 24 | * @param props.node - The editable node to render. 25 | * @param props.depth - The depth of the node in the tree. 26 | * @param props.isDragging - Whether the node is being dragged. 27 | * @param props.onChange - A function to call when the node changes. 28 | * @param props.onClose - A function to call when the node is closed. 29 | * @returns An editable node component. 30 | */ 31 | export const EditableNode: FunctionComponent = ({ 32 | node, 33 | depth, 34 | isDragging, 35 | onChange, 36 | onClose, 37 | }) => { 38 | const text = getNodeText(node); 39 | assert(text !== null, 'Node must have text.'); 40 | 41 | const [value, setValue] = useState(text); 42 | 43 | const handleChange = (newValue: ChangeEvent) => { 44 | setValue(newValue.target.value); 45 | onChange?.(node, newValue.target.value); 46 | }; 47 | 48 | return ( 49 | 50 | 51 | 69 | 70 | 71 | ); 72 | }; 73 | -------------------------------------------------------------------------------- /src/features/builder/components/Node.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from '@chakra-ui/react'; 2 | import { Component, NodeType } from '@metamask/snaps-ui'; 3 | import { assert } from '@metamask/utils'; 4 | import { NodeModel } from '@minoru/react-dnd-treeview'; 5 | import { FunctionComponent } from 'react'; 6 | 7 | import { BaseNode } from './BaseNode'; 8 | import { EditableComponent, EditableNode } from './EditableNode'; 9 | 10 | export const EDITABLE_NODES = [ 11 | NodeType.Heading, 12 | NodeType.Text, 13 | NodeType.Copyable, 14 | ]; 15 | 16 | type NodeProps = { 17 | node: NodeModel; 18 | depth: number; 19 | isDragging: boolean; 20 | onChange: (node: NodeModel, value: string) => void; 21 | onClose?: ((node: NodeModel) => void) | undefined; 22 | }; 23 | 24 | /** 25 | * A node, which renders a component in the builder. The node can be editable or 26 | * non-editable. 27 | * 28 | * @param props - The props of the component. 29 | * @param props.node - The node to render. 30 | * @param props.depth - The depth of the node in the tree. 31 | * @param props.isDragging - Whether the node is being dragged. 32 | * @param props.onChange - A function to call when the node changes. 33 | * @param props.onClose - A function to call when the node is closed. 34 | * @returns A node component. 35 | */ 36 | export const Node: FunctionComponent = ({ 37 | node, 38 | depth, 39 | isDragging, 40 | onChange, 41 | onClose, 42 | }) => { 43 | assert(node.data?.type, 'Node must have a type.'); 44 | if (EDITABLE_NODES.includes(node.data.type)) { 45 | return ( 46 | } 48 | depth={depth} 49 | isDragging={isDragging} 50 | onChange={onChange} 51 | onClose={onClose} 52 | /> 53 | ); 54 | } 55 | 56 | return ( 57 | 58 | 59 | 60 | ); 61 | }; 62 | -------------------------------------------------------------------------------- /src/features/builder/components/NodeRenderer.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from '@chakra-ui/react'; 2 | import { Component } from '@metamask/snaps-ui'; 3 | import { NodeModel } from '@minoru/react-dnd-treeview'; 4 | import { FunctionComponent, useMemo } from 'react'; 5 | import { useSelector } from 'react-redux'; 6 | 7 | import { Delineator, Window } from '../../../components'; 8 | import { Renderer } from '../../renderer'; 9 | import { DEFAULT_SNAP_ID, getSnapName } from '../../simulation'; 10 | import { nodeModelsToComponent } from '../utils'; 11 | 12 | export type NodeRendererProps = { 13 | items: NodeModel[]; 14 | }; 15 | 16 | /** 17 | * A node renderer, which renders the result of a node tree. The tree is 18 | * converted to a component, which is then rendered in the MetaMask window. 19 | * 20 | * @param props - The props of the component. 21 | * @param props.items - The items to render in the tree. 22 | * @returns A node renderer component. 23 | */ 24 | export const NodeRenderer: FunctionComponent = ({ 25 | items, 26 | }) => { 27 | const snapName = useSelector(getSnapName) ?? 'Unknown'; 28 | const node = useMemo(() => nodeModelsToComponent(items), [items]); 29 | 30 | return ( 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | ); 39 | }; 40 | -------------------------------------------------------------------------------- /src/features/builder/components/Start.tsx: -------------------------------------------------------------------------------- 1 | import { Flex, Heading, Text } from '@chakra-ui/react'; 2 | import { FunctionComponent } from 'react'; 3 | 4 | import { Icon } from '../../../components'; 5 | 6 | export const Start: FunctionComponent = () => ( 7 | 18 | 19 | 26 | Drag components in here 27 | 28 | 29 | Build your UI piece-by-piece using the 30 |
31 | predefined components above. 32 |
33 |
34 | ); 35 | -------------------------------------------------------------------------------- /src/features/builder/components/TemplateComponent.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from '@chakra-ui/react'; 2 | import { Component } from '@metamask/snaps-ui'; 3 | import { NodeModel } from '@minoru/react-dnd-treeview'; 4 | import { FunctionComponent } from 'react'; 5 | import { useDrag } from 'react-dnd'; 6 | 7 | import { IconName, Prefill } from '../../../components'; 8 | 9 | type TemplateComponentProps = { 10 | node: NodeModel; 11 | icon: IconName; 12 | incrementId: () => void; 13 | }; 14 | 15 | export const TemplateComponent: FunctionComponent = ({ 16 | node, 17 | icon, 18 | incrementId, 19 | }) => { 20 | const [, drag] = useDrag({ 21 | type: 'template', 22 | item: node, 23 | end: (item, monitor) => { 24 | const dropResult = monitor.getDropResult(); 25 | if (item && dropResult) { 26 | incrementId(); 27 | } 28 | }, 29 | }); 30 | 31 | return ( 32 | 39 | {node.text} 40 | 41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /src/features/builder/components/TemplateComponentList.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Flex, List, ListItem, Text } from '@chakra-ui/react'; 2 | import { 3 | Component, 4 | copyable, 5 | divider, 6 | heading, 7 | panel, 8 | text, 9 | } from '@metamask/snaps-ui'; 10 | import { FunctionComponent } from 'react'; 11 | 12 | import { IconName } from '../../../components'; 13 | import { TemplateComponent } from './TemplateComponent'; 14 | 15 | type TemplateComponent = { 16 | icon: IconName; 17 | text: string; 18 | data: Component; 19 | droppable: boolean; 20 | }; 21 | 22 | const TEMPLATE_COMPONENTS: TemplateComponent[] = [ 23 | { 24 | icon: 'panel', 25 | text: 'Panel', 26 | data: panel([]), 27 | droppable: true, 28 | }, 29 | { 30 | icon: 'heading', 31 | text: 'Heading', 32 | data: heading('Heading'), 33 | droppable: false, 34 | }, 35 | { 36 | icon: 'text', 37 | text: 'Text', 38 | data: text('Text'), 39 | droppable: false, 40 | }, 41 | { 42 | icon: 'divider', 43 | text: 'Divider', 44 | data: divider(), 45 | droppable: false, 46 | }, 47 | { 48 | icon: 'copyable', 49 | text: 'Copyable', 50 | data: copyable('Copyable text'), 51 | droppable: false, 52 | }, 53 | ]; 54 | 55 | export type ComponentsListProps = { 56 | nextId: number; 57 | incrementId: () => void; 58 | }; 59 | 60 | export const TemplateComponentList: FunctionComponent = ({ 61 | nextId, 62 | incrementId, 63 | }) => ( 64 | 65 | 72 | Components 73 | 74 | 75 | {TEMPLATE_COMPONENTS.map((component) => ( 76 | 77 | 88 | 89 | ))} 90 | 91 | 92 | ); 93 | -------------------------------------------------------------------------------- /src/features/builder/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './TemplateComponentList'; 2 | export * from './EditableNode'; 3 | export * from './Node'; 4 | export * from './NodeRenderer'; 5 | export * from './NodeTree'; 6 | -------------------------------------------------------------------------------- /src/features/builder/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Builder'; 2 | -------------------------------------------------------------------------------- /src/features/builder/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { Component, panel, text } from '@metamask/snaps-ui'; 2 | import { NodeModel } from '@minoru/react-dnd-treeview'; 3 | 4 | import { panelToCode, nodeModelsToComponent } from './utils'; 5 | 6 | describe('nodeModelsToComponent', () => { 7 | it('creates a component from an array of node models', () => { 8 | const nodeModels: NodeModel[] = [ 9 | { 10 | id: 1, 11 | parent: 0, 12 | text: 'parent', 13 | data: panel([]), 14 | }, 15 | { 16 | id: 2, 17 | parent: 1, 18 | text: 'child', 19 | data: panel([]), 20 | }, 21 | { 22 | id: 3, 23 | parent: 2, 24 | text: 'child', 25 | data: text('foo'), 26 | }, 27 | ]; 28 | 29 | const component = nodeModelsToComponent(nodeModels); 30 | expect(component).toStrictEqual(panel([panel([text('foo')])])); 31 | }); 32 | }); 33 | 34 | describe('paneltoCode', () => { 35 | it('creates code from a component', () => { 36 | const component: Component = panel([ 37 | text('foo'), 38 | panel([text('bar'), text('baz')]), 39 | ]); 40 | 41 | const code = panelToCode(component); 42 | expect(code).toMatchInlineSnapshot(` 43 | "import { panel, text } from '@metamask/snaps-ui'; 44 | 45 | const component = panel([text('foo'), panel([text('bar'), text('baz')])]); 46 | " 47 | `); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /src/features/configuration/Configuration.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '../../utils'; 2 | import { Configuration } from './Configuration'; 3 | 4 | describe('Configuration', () => { 5 | it('renders', () => { 6 | expect(() => render()).not.toThrow(); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /src/features/configuration/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Configuration'; 2 | export * from './slice'; 3 | -------------------------------------------------------------------------------- /src/features/configuration/slice.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | configuration as reducer, 3 | INITIAL_CONFIGURATION_STATE, 4 | openConfigurationModal, 5 | setOpen, 6 | setSesEnabled, 7 | setSnapUrl, 8 | setSrp, 9 | } from './slice'; 10 | 11 | describe('configuration slice', () => { 12 | describe('openConfigurationModal', () => { 13 | it('opens the modal', () => { 14 | const result = reducer( 15 | INITIAL_CONFIGURATION_STATE, 16 | openConfigurationModal(), 17 | ); 18 | 19 | expect(result.open).toBe(true); 20 | }); 21 | }); 22 | 23 | describe('setOpen', () => { 24 | it('sets the open state', () => { 25 | const result = reducer(INITIAL_CONFIGURATION_STATE, setOpen(true)); 26 | 27 | expect(result.open).toBe(true); 28 | }); 29 | }); 30 | 31 | describe('setSnapUrl', () => { 32 | it('sets the snap URL', () => { 33 | const url = 'http://localhost:9090'; 34 | const result = reducer(INITIAL_CONFIGURATION_STATE, setSnapUrl(url)); 35 | 36 | expect(result.snapUrl).toStrictEqual(url); 37 | }); 38 | }); 39 | 40 | describe('setSrp', () => { 41 | it('sets the SRP', () => { 42 | const srp = 'test test test test test test test test test test test ball'; 43 | const result = reducer(INITIAL_CONFIGURATION_STATE, setSrp(srp)); 44 | 45 | expect(result.srp).toStrictEqual(srp); 46 | }); 47 | }); 48 | 49 | describe('setSesEnabled', () => { 50 | it('sets the SES enabled flag', () => { 51 | const result = reducer(INITIAL_CONFIGURATION_STATE, setSesEnabled(false)); 52 | 53 | expect(result.sesEnabled).toBe(false); 54 | }); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /src/features/configuration/slice.ts: -------------------------------------------------------------------------------- 1 | import { createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit'; 2 | 3 | export const DEFAULT_SRP = 4 | 'test test test test test test test test test test test ball'; 5 | 6 | export const INITIAL_CONFIGURATION_STATE = { 7 | open: false, 8 | snapUrl: 'http://localhost:8080', 9 | srp: DEFAULT_SRP, 10 | sesEnabled: true, 11 | }; 12 | 13 | const slice = createSlice({ 14 | name: 'configuration', 15 | initialState: INITIAL_CONFIGURATION_STATE, 16 | reducers: { 17 | openConfigurationModal(state) { 18 | state.open = true; 19 | }, 20 | setOpen(state, action: PayloadAction) { 21 | state.open = action.payload; 22 | }, 23 | setSnapUrl(state, action: PayloadAction) { 24 | state.snapUrl = action.payload; 25 | }, 26 | setSrp(state, action: PayloadAction) { 27 | state.srp = action.payload; 28 | }, 29 | setSesEnabled(state, action: PayloadAction) { 30 | state.sesEnabled = action.payload; 31 | }, 32 | }, 33 | }); 34 | 35 | export const { 36 | openConfigurationModal, 37 | setOpen, 38 | setSnapUrl, 39 | setSrp, 40 | setSesEnabled, 41 | } = slice.actions; 42 | export const configuration = slice.reducer; 43 | 44 | export const getOpen = createSelector( 45 | (state: { configuration: typeof INITIAL_CONFIGURATION_STATE }) => 46 | state.configuration, 47 | (state) => state.open, 48 | ); 49 | 50 | export const getSnapUrl = createSelector( 51 | (state: { configuration: typeof INITIAL_CONFIGURATION_STATE }) => 52 | state.configuration, 53 | (state) => state.snapUrl, 54 | ); 55 | 56 | export const getSrp = createSelector( 57 | (state: { configuration: typeof INITIAL_CONFIGURATION_STATE }) => 58 | state.configuration, 59 | (state) => state.srp, 60 | ); 61 | 62 | export const getSesEnabled = createSelector( 63 | (state: { configuration: typeof INITIAL_CONFIGURATION_STATE }) => 64 | state.configuration, 65 | (state) => state.sesEnabled, 66 | ); 67 | -------------------------------------------------------------------------------- /src/features/console/Console.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Flex, 3 | Tab, 4 | TabList, 5 | TabPanel, 6 | TabPanels, 7 | Tabs, 8 | } from '@chakra-ui/react'; 9 | import { FunctionComponent, useEffect, useRef, useState } from 'react'; 10 | 11 | import { Icon } from '../../components'; 12 | import { useSelector } from '../../hooks'; 13 | import { ConsoleContent } from './ConsoleContent'; 14 | import { getConsoleEntries } from './slice'; 15 | 16 | /** 17 | * Console component. 18 | * 19 | * @returns The console component. 20 | */ 21 | export const Console: FunctionComponent = () => { 22 | const ref = useRef(null); 23 | 24 | const [collapsed, setCollapsed] = useState(false); 25 | 26 | const entries = useSelector(getConsoleEntries); 27 | 28 | useEffect(() => { 29 | if (ref.current) { 30 | // TODO: Maybe not scroll if the user is scrolled up? 31 | ref.current.scrollTop = ref.current.scrollHeight; 32 | } 33 | }, [entries]); 34 | 35 | const handleToggleConsole = () => { 36 | setCollapsed((state) => !state); 37 | }; 38 | 39 | return ( 40 | 48 | 49 | 50 | Console 51 | 59 | 64 | 65 | 66 | 72 | 79 | 80 | 81 | 82 | 83 | 84 | ); 85 | }; 86 | -------------------------------------------------------------------------------- /src/features/console/ConsoleContent.tsx: -------------------------------------------------------------------------------- 1 | import { Text } from '@chakra-ui/react'; 2 | 3 | import { useSelector } from '../../hooks'; 4 | import { ConsoleEntryType, getConsoleEntries } from './slice'; 5 | 6 | export const ConsoleContent = () => { 7 | const entries = useSelector(getConsoleEntries); 8 | 9 | const colors = { 10 | [ConsoleEntryType.Log]: 'text.alternative', 11 | [ConsoleEntryType.Error]: 'text.error', 12 | }; 13 | 14 | return ( 15 | <> 16 | {entries.map((entry) => ( 17 | 24 | {entry.message} 25 | 26 | ))} 27 | 28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /src/features/console/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Console'; 2 | export * from './slice'; 3 | -------------------------------------------------------------------------------- /src/features/console/slice.ts: -------------------------------------------------------------------------------- 1 | import { createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit'; 2 | 3 | export enum ConsoleEntryType { 4 | Log, 5 | Error, 6 | } 7 | 8 | export type ConsoleEntry = { 9 | date: number; 10 | type: ConsoleEntryType; 11 | message: string; 12 | }; 13 | 14 | const INITIAL_STATE = { 15 | entries: [] as ConsoleEntry[], 16 | }; 17 | 18 | const slice = createSlice({ 19 | name: 'console', 20 | initialState: INITIAL_STATE, 21 | reducers: { 22 | logDefault(state, action: PayloadAction) { 23 | state.entries.push({ 24 | date: Date.now(), 25 | type: ConsoleEntryType.Log, 26 | message: action.payload, 27 | }); 28 | }, 29 | logError(state, action: PayloadAction) { 30 | state.entries.push({ 31 | date: Date.now(), 32 | type: ConsoleEntryType.Error, 33 | message: action.payload.stack ?? action.payload.message, 34 | }); 35 | }, 36 | }, 37 | }); 38 | 39 | export const { logDefault, logError } = slice.actions; 40 | export const console = slice.reducer; 41 | 42 | export const getConsoleEntries = createSelector( 43 | (state: { console: typeof INITIAL_STATE }) => state.console, 44 | (state) => state.entries, 45 | ); 46 | -------------------------------------------------------------------------------- /src/features/handlers/components/Handler.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '../../../utils'; 2 | import { Handler } from './Handler'; 3 | 4 | describe('Handler', () => { 5 | it('renders', () => { 6 | expect(() => render(, '/handler/onRpcRequest')).not.toThrow(); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /src/features/handlers/components/History.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '../../../utils'; 2 | import { History } from './History'; 3 | 4 | describe('History', () => { 5 | it('renders', () => { 6 | expect(() => render(, '/handler/onRpcRequest')).not.toThrow(); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /src/features/handlers/components/History.tsx: -------------------------------------------------------------------------------- 1 | import { Center, Heading, List, Text } from '@chakra-ui/react'; 2 | import { FunctionComponent } from 'react'; 3 | 4 | import { Icon } from '../../../components'; 5 | import { useHandler, useSelector } from '../../../hooks'; 6 | import { HistoryItem } from './HistoryItem'; 7 | 8 | export const History: FunctionComponent = () => { 9 | const handler = useHandler(); 10 | const history = useSelector((state) => state[handler].history); 11 | const sortedHistory = [...history].sort( 12 | (a, b) => b.date.getTime() - a.date.getTime(), 13 | ); 14 | 15 | if (sortedHistory.length === 0) { 16 | return ( 17 |
22 | 23 | 30 | No history yet 31 | 32 | 33 | Create a request via the 34 |
35 | request tab to get started. 36 |
37 |
38 | ); 39 | } 40 | 41 | return ( 42 | 43 | {sortedHistory.map((item, index) => ( 44 | 45 | ))} 46 | 47 | ); 48 | }; 49 | -------------------------------------------------------------------------------- /src/features/handlers/components/HistoryItem.test.tsx: -------------------------------------------------------------------------------- 1 | import { List, Tabs } from '@chakra-ui/react'; 2 | 3 | import { render } from '../../../utils'; 4 | import { HistoryItem } from './HistoryItem'; 5 | 6 | describe('HistoryItem', () => { 7 | it('renders', () => { 8 | expect(() => 9 | render( 10 | 11 | 12 | 13 | 14 | , 15 | '/handler/onRpcRequest', 16 | ), 17 | ).not.toThrow(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/features/handlers/components/HistoryItem.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Flex, 4 | ListItem, 5 | Popover, 6 | PopoverContent, 7 | PopoverTrigger, 8 | Text, 9 | useTabsContext, 10 | } from '@chakra-ui/react'; 11 | import { JsonRpcRequest } from '@metamask/utils'; 12 | import { formatDistance } from 'date-fns'; 13 | import { FunctionComponent } from 'react'; 14 | 15 | import { Icon } from '../../../components'; 16 | import { useDispatch, useHandler } from '../../../hooks'; 17 | import { HistoryEntry } from '../slice'; 18 | 19 | export type HistoryItemProps = { 20 | item: HistoryEntry; 21 | }; 22 | 23 | export const HistoryItem: FunctionComponent< 24 | HistoryItemProps<{ request?: JsonRpcRequest }> 25 | > = ({ item }) => { 26 | const dispatch = useDispatch(); 27 | const tab = useTabsContext(); 28 | const handler = useHandler(); 29 | 30 | const handleClick = () => { 31 | dispatch({ 32 | type: `${handler}/setRequestFromHistory`, 33 | payload: item.request, 34 | }); 35 | tab.setSelectedIndex(0); 36 | }; 37 | 38 | return ( 39 | 40 | 41 | 42 | 54 | 55 | 62 | {item.request?.request?.method} 63 | 64 | 65 | {formatDistance(item.date, new Date(), { addSuffix: true })} 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 81 | 88 | {JSON.stringify(item.request?.request, null, 2)} 89 | 90 | 91 | 92 | 93 | ); 94 | }; 95 | -------------------------------------------------------------------------------- /src/features/handlers/components/PlayButton.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Button, Spinner } from '@chakra-ui/react'; 2 | import { hasProperty } from '@metamask/utils'; 3 | import { useEffect, useState } from 'react'; 4 | 5 | import { Icon } from '../../../components'; 6 | import { useHandler, useSelector } from '../../../hooks'; 7 | 8 | enum PlayButtonState { 9 | Ready, 10 | Disabled, 11 | Loading, 12 | Success, 13 | Error, 14 | } 15 | 16 | export const PlayButton = () => { 17 | const handler = useHandler(); 18 | const response = useSelector((state) => state[handler].response); 19 | 20 | const isError = response && hasProperty(response, 'error'); 21 | const isSuccess = response && !hasProperty(response, 'error'); 22 | const isLoading = useSelector((state) => state[handler].pending); 23 | 24 | const [state, setState] = useState(PlayButtonState.Ready); 25 | 26 | useEffect(() => { 27 | if (isLoading) { 28 | setState(PlayButtonState.Loading); 29 | } else if (isError) { 30 | setState(PlayButtonState.Error); 31 | } else if (isSuccess) { 32 | setState(PlayButtonState.Success); 33 | } 34 | }, [isLoading, isError, isSuccess]); 35 | 36 | // eslint-disable-next-line consistent-return 37 | useEffect(() => { 38 | if (state === PlayButtonState.Success || state === PlayButtonState.Error) { 39 | const timeout = setTimeout(() => { 40 | setState(PlayButtonState.Ready); 41 | }, 1500); 42 | 43 | return () => clearTimeout(timeout); 44 | } 45 | }, [state]); 46 | 47 | const icons = { 48 | [PlayButtonState.Ready]: 'play', 49 | [PlayButtonState.Disabled]: 'playMuted', 50 | [PlayButtonState.Success]: 'playSuccess', 51 | [PlayButtonState.Error]: 'playError', 52 | } as any; 53 | 54 | return ( 55 | 71 | ); 72 | }; 73 | -------------------------------------------------------------------------------- /src/features/handlers/components/ResetTab.tsx: -------------------------------------------------------------------------------- 1 | import { useTabsContext } from '@chakra-ui/react'; 2 | import { FunctionComponent, useEffect } from 'react'; 3 | 4 | import { useHandler } from '../../../hooks'; 5 | 6 | /** 7 | * Resets the tab to the first tab when the handler changes. 8 | * 9 | * @returns `null`. 10 | */ 11 | export const ResetTab: FunctionComponent = () => { 12 | const handler = useHandler(); 13 | const tab = useTabsContext(); 14 | 15 | useEffect(() => { 16 | tab.setSelectedIndex(0); 17 | }, [handler]); 18 | 19 | return null; 20 | }; 21 | -------------------------------------------------------------------------------- /src/features/handlers/components/ResetUserInterfaceTab.tsx: -------------------------------------------------------------------------------- 1 | import { useTabsContext } from '@chakra-ui/react'; 2 | import { FunctionComponent, useEffect } from 'react'; 3 | 4 | import { useSelector } from '../../../hooks'; 5 | import { getUserInterface } from '../../simulation'; 6 | 7 | /** 8 | * Resets the tab to the first tab when the user interface is closed. 9 | * 10 | * @returns `null`. 11 | */ 12 | export const ResetUserInterfaceTab: FunctionComponent = () => { 13 | const userInterface = useSelector(getUserInterface); 14 | const tab = useTabsContext(); 15 | 16 | useEffect(() => { 17 | if (!userInterface) { 18 | tab.setSelectedIndex(0); 19 | } 20 | }, [userInterface]); 21 | 22 | return null; 23 | }; 24 | -------------------------------------------------------------------------------- /src/features/handlers/components/Response.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '../../../utils'; 2 | import { Response } from './Response'; 3 | 4 | describe('Response', () => { 5 | it('renders', () => { 6 | expect(() => render(, '/handler/onRpcRequest')).not.toThrow(); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /src/features/handlers/components/Response.tsx: -------------------------------------------------------------------------------- 1 | import { Center, Heading, Text, Box, Skeleton } from '@chakra-ui/react'; 2 | import { HandlerType } from '@metamask/snaps-utils'; 3 | 4 | import { Delineator, Editor, Icon, Window } from '../../../components'; 5 | import { useSelector, useHandler } from '../../../hooks'; 6 | import { Renderer } from '../../renderer'; 7 | import { DEFAULT_SNAP_ID, getSnapName } from '../../simulation'; 8 | 9 | export const Response = () => { 10 | const handler = useHandler(); 11 | const response = useSelector((state) => state[handler].response); 12 | const snapName = useSelector(getSnapName) as string; 13 | 14 | if (!response) { 15 | return ( 16 |
21 | 22 | 29 | No response yet 30 | 31 | 32 | Create a request via the 33 |
34 | left-side config to get started. 35 |
36 |
37 | ); 38 | } 39 | 40 | const content = (response as any).result?.content; 41 | 42 | if (handler === HandlerType.OnTransaction && content) { 43 | return ( 44 | 45 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | ); 60 | } 61 | 62 | return ( 63 | 68 | ); 69 | }; 70 | -------------------------------------------------------------------------------- /src/features/handlers/components/UserInterface.test.tsx: -------------------------------------------------------------------------------- 1 | import { Tabs } from '@chakra-ui/react'; 2 | 3 | import { render } from '../../../utils'; 4 | import { UserInterface } from './UserInterface'; 5 | 6 | describe('UserInterface', () => { 7 | it('renders', () => { 8 | expect(() => 9 | render( 10 | 11 | 12 | , 13 | '/handler/onRpcRequest', 14 | ), 15 | ).not.toThrow(); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/features/handlers/components/UserInterface.tsx: -------------------------------------------------------------------------------- 1 | import { useTabsContext } from '@chakra-ui/react'; 2 | import { DialogType } from '@metamask/rpc-methods'; 3 | import { FunctionComponent, useEffect } from 'react'; 4 | 5 | import { 6 | AlertDialog, 7 | ConfirmationDialog, 8 | PromptDialog, 9 | } from '../../../components'; 10 | import { useDispatch, useSelector } from '../../../hooks'; 11 | import { getUserInterface, resolveUserInterface } from '../../simulation'; 12 | 13 | export const UserInterface: FunctionComponent = () => { 14 | const dispatch = useDispatch(); 15 | const ui = useSelector(getUserInterface); 16 | const tab = useTabsContext(); 17 | 18 | useEffect(() => { 19 | tab.setSelectedIndex(1); 20 | }, []); 21 | 22 | if (!ui) { 23 | return null; 24 | } 25 | 26 | const { snapName, snapId, type, node } = ui; 27 | 28 | switch (type) { 29 | case DialogType.Alert: { 30 | const handleClose = () => { 31 | dispatch(resolveUserInterface(null)); 32 | }; 33 | 34 | return ( 35 | 41 | ); 42 | } 43 | case DialogType.Confirmation: { 44 | const handleCancel = () => { 45 | dispatch(resolveUserInterface(false)); 46 | }; 47 | 48 | const handleApprove = () => { 49 | dispatch(resolveUserInterface(true)); 50 | }; 51 | 52 | return ( 53 | 60 | ); 61 | } 62 | case DialogType.Prompt: { 63 | const handleCancel = () => { 64 | dispatch(resolveUserInterface(null)); 65 | }; 66 | 67 | const handleSubmit = (value: string) => { 68 | dispatch(resolveUserInterface(value)); 69 | }; 70 | 71 | return ( 72 | 79 | ); 80 | } 81 | default: 82 | return null; 83 | } 84 | }; 85 | -------------------------------------------------------------------------------- /src/features/handlers/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Handler'; 2 | export * from './Response'; 3 | -------------------------------------------------------------------------------- /src/features/handlers/cronjobs/Cronjobs.test.tsx: -------------------------------------------------------------------------------- 1 | import { Cronjobs } from './Cronjobs'; 2 | 3 | describe('Cronjobs', () => { 4 | it('renders', () => { 5 | expect(() => ).not.toThrow(); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /src/features/handlers/cronjobs/Cronjobs.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent } from 'react'; 2 | 3 | import { Request } from './components'; 4 | 5 | export const Cronjobs: FunctionComponent = () => ; 6 | -------------------------------------------------------------------------------- /src/features/handlers/cronjobs/components/CronjobPrefill.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '../../../../utils'; 2 | import { CronjobPrefill } from './CronjobPrefill'; 3 | 4 | describe('CronjobPrefill', () => { 5 | it('renders', () => { 6 | expect(() => 7 | render(), 8 | ).not.toThrow(); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /src/features/handlers/cronjobs/components/CronjobPrefill.tsx: -------------------------------------------------------------------------------- 1 | import { Json } from '@metamask/utils'; 2 | import { FunctionComponent } from 'react'; 3 | 4 | import { Prefill } from '../../../../components/Prefill'; 5 | 6 | export type CronjobData = { 7 | method: string; 8 | params?: Json | undefined; 9 | }; 10 | 11 | export type CronjobPrefillProps = CronjobData & { 12 | onClick: (prefill: CronjobData) => void; 13 | }; 14 | 15 | export const CronjobPrefill: FunctionComponent = ({ 16 | method, 17 | params, 18 | onClick, 19 | }) => { 20 | const handleClick = () => { 21 | onClick({ method, params }); 22 | }; 23 | 24 | return ( 25 | 26 | {method} 27 | 28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /src/features/handlers/cronjobs/components/CronjobPrefills.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '../../../../utils'; 2 | import { CronjobPrefills } from './CronjobPrefills'; 3 | 4 | describe('CronjobPrefills', () => { 5 | it('renders', () => { 6 | expect(() => render()).not.toThrow(); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /src/features/handlers/cronjobs/components/CronjobPrefills.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Flex, Text } from '@chakra-ui/react'; 2 | import { FunctionComponent } from 'react'; 3 | 4 | import { useSelector } from '../../../../hooks'; 5 | import { getSnapManifest } from '../../../simulation'; 6 | import { CronjobData, CronjobPrefill } from './CronjobPrefill'; 7 | 8 | export type CronjobPrefillsProps = { 9 | onClick: (prefill: CronjobData) => void; 10 | }; 11 | 12 | export const CronjobPrefills: FunctionComponent = ({ 13 | onClick, 14 | }) => { 15 | const manifest = useSelector(getSnapManifest); 16 | const jobs = manifest?.initialPermissions?.['endowment:cronjob']?.jobs; 17 | 18 | if (!jobs?.length) { 19 | return null; 20 | } 21 | 22 | return ( 23 | 24 | 25 | Manifest cronjobs 26 | 27 | 28 | {jobs.map(({ request: { method, params } }, index) => ( 29 | 35 | ))} 36 | 37 | 38 | ); 39 | }; 40 | -------------------------------------------------------------------------------- /src/features/handlers/cronjobs/components/Request.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '../../../../utils'; 2 | import { Request } from './Request'; 3 | 4 | jest.mock('react-monaco-editor'); 5 | 6 | describe('Request', () => { 7 | it('renders', () => { 8 | expect(() => render()).not.toThrow(); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /src/features/handlers/cronjobs/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Request'; 2 | -------------------------------------------------------------------------------- /src/features/handlers/cronjobs/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Cronjobs'; 2 | export * from './slice'; 3 | -------------------------------------------------------------------------------- /src/features/handlers/cronjobs/slice.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | cronjob as reducer, 3 | INITIAL_STATE, 4 | setCronjobRequest, 5 | setCronjobRequestFromHistory, 6 | setCronjobResponse, 7 | } from './slice'; 8 | 9 | describe('cronjobs', () => { 10 | describe('setCronjobRequest', () => { 11 | it('sets the request', () => { 12 | const result = reducer( 13 | INITIAL_STATE, 14 | setCronjobRequest({ origin: 'foo' }), 15 | ); 16 | 17 | expect(result.request).toStrictEqual({ origin: 'foo' }); 18 | }); 19 | 20 | it('pushes the request to history', () => { 21 | const result = reducer( 22 | INITIAL_STATE, 23 | setCronjobRequest({ origin: 'foo' }), 24 | ); 25 | 26 | expect(result.history).toStrictEqual([ 27 | { date: expect.any(Date), request: { origin: 'foo' } }, 28 | ]); 29 | }); 30 | }); 31 | 32 | describe('setCronjobRequestFromHistory', () => { 33 | it('sets the request', () => { 34 | const result = reducer( 35 | INITIAL_STATE, 36 | setCronjobRequestFromHistory({ origin: 'foo' }), 37 | ); 38 | 39 | expect(result.request).toStrictEqual({ origin: 'foo' }); 40 | }); 41 | 42 | it('does not push to history', () => { 43 | const result = reducer( 44 | INITIAL_STATE, 45 | setCronjobRequestFromHistory({ origin: 'foo' }), 46 | ); 47 | 48 | expect(result.history).toStrictEqual([]); 49 | }); 50 | }); 51 | 52 | describe('setCronjobResponse', () => { 53 | it('sets the response', () => { 54 | const result = reducer(INITIAL_STATE, setCronjobResponse('foo')); 55 | 56 | expect(result.response).toBe('foo'); 57 | }); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /src/features/handlers/cronjobs/slice.ts: -------------------------------------------------------------------------------- 1 | import { HandlerType } from '@metamask/snaps-utils'; 2 | import { JsonRpcRequest } from '@metamask/utils'; 3 | import { createSelector } from '@reduxjs/toolkit'; 4 | 5 | import { createHandlerSlice } from '../slice'; 6 | 7 | type Request = { 8 | origin: string; 9 | request?: JsonRpcRequest; 10 | }; 11 | 12 | type Response = string; 13 | 14 | export const INITIAL_STATE = { 15 | request: { 16 | origin: '', 17 | }, 18 | response: null, 19 | history: [], 20 | }; 21 | 22 | const slice = createHandlerSlice({ 23 | name: HandlerType.OnCronjob, 24 | initialState: INITIAL_STATE, 25 | }); 26 | 27 | export const cronjob = slice.reducer; 28 | export const { 29 | setRequest: setCronjobRequest, 30 | setRequestFromHistory: setCronjobRequestFromHistory, 31 | setResponse: setCronjobResponse, 32 | } = slice.actions; 33 | 34 | export const getCronjobRequest = createSelector( 35 | (state: { 36 | [HandlerType.OnCronjob]: ReturnType; 37 | }) => state[HandlerType.OnCronjob], 38 | (state) => state.request, 39 | ); 40 | -------------------------------------------------------------------------------- /src/features/handlers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './components'; 2 | export { Cronjobs, cronjob } from './cronjobs'; 3 | export { JsonRpc, jsonRpc } from './json-rpc'; 4 | export { Transactions, transactions } from './transactions'; 5 | -------------------------------------------------------------------------------- /src/features/handlers/json-rpc/JsonRpc.test.tsx: -------------------------------------------------------------------------------- 1 | import { JsonRpc } from './JsonRpc'; 2 | 3 | describe('JsonRpc', () => { 4 | it('renders', () => { 5 | expect(() => ).not.toThrow(); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /src/features/handlers/json-rpc/JsonRpc.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent } from 'react'; 2 | 3 | import { Request } from './components'; 4 | 5 | export const JsonRpc: FunctionComponent = () => ; 6 | -------------------------------------------------------------------------------- /src/features/handlers/json-rpc/components/Request.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Flex, 3 | FormControl, 4 | FormErrorMessage, 5 | FormLabel, 6 | Input, 7 | } from '@chakra-ui/react'; 8 | import { HandlerType } from '@metamask/snaps-utils'; 9 | import { FunctionComponent } from 'react'; 10 | import { Controller, useForm } from 'react-hook-form'; 11 | 12 | import { Editor } from '../../../../components'; 13 | import { useDispatch, useSelector } from '../../../../hooks'; 14 | import { sendRequest } from '../../../simulation'; 15 | import { SAMPLE_JSON_RPC_REQUEST } from '../schema'; 16 | import { getJsonRpcRequest } from '../slice'; 17 | 18 | type JsonRpcFormData = { 19 | origin: string; 20 | request: string; 21 | }; 22 | 23 | export const Request: FunctionComponent = () => { 24 | const { request, origin } = useSelector(getJsonRpcRequest); 25 | const { 26 | handleSubmit, 27 | register, 28 | control, 29 | formState: { errors }, 30 | } = useForm({ 31 | defaultValues: { 32 | origin: origin ?? '', 33 | request: request 34 | ? JSON.stringify(request, null, 2) 35 | : SAMPLE_JSON_RPC_REQUEST, 36 | }, 37 | }); 38 | 39 | const dispatch = useDispatch(); 40 | 41 | const onSubmit = (data: JsonRpcFormData) => { 42 | dispatch( 43 | sendRequest({ 44 | origin: data.origin, 45 | handler: HandlerType.OnRpcRequest, 46 | request: JSON.parse(data.request), 47 | }), 48 | ); 49 | }; 50 | 51 | return ( 52 | 60 | 61 | Origin 62 | 68 | {errors.origin?.message} 69 | 70 | 71 | 77 | Request 78 | ( 82 | 83 | )} 84 | /> 85 | 86 | 87 | ); 88 | }; 89 | -------------------------------------------------------------------------------- /src/features/handlers/json-rpc/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Request'; 2 | -------------------------------------------------------------------------------- /src/features/handlers/json-rpc/index.ts: -------------------------------------------------------------------------------- 1 | export * from './JsonRpc'; 2 | export * from './slice'; 3 | -------------------------------------------------------------------------------- /src/features/handlers/json-rpc/schema.ts: -------------------------------------------------------------------------------- 1 | export const JSON_RPC_SCHEMA_URL = 'http://json-schema.org/draft-04/schema#'; 2 | export const JSON_RPC_SCHEMA = { 3 | $schema: 'http://json-schema.org/draft-04/schema#', 4 | type: 'object', 5 | properties: { 6 | jsonrpc: { 7 | const: '2.0', 8 | }, 9 | id: { 10 | oneOf: [ 11 | { 12 | type: 'string', 13 | }, 14 | { 15 | type: 'number', 16 | }, 17 | { 18 | type: 'null', 19 | }, 20 | ], 21 | }, 22 | method: { 23 | type: 'string', 24 | }, 25 | params: { 26 | type: ['number', 'string', 'boolean', 'object', 'array', 'null'], 27 | }, 28 | }, 29 | required: ['jsonrpc', 'id', 'method'], 30 | additionalProperties: false, 31 | }; 32 | 33 | export const SAMPLE_JSON_RPC_REQUEST = JSON.stringify( 34 | { 35 | jsonrpc: '2.0', 36 | id: 1, 37 | method: 'subtract', 38 | params: [42, 23], 39 | }, 40 | null, 41 | 2, 42 | ); 43 | -------------------------------------------------------------------------------- /src/features/handlers/json-rpc/slice.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | jsonRpc as reducer, 3 | setJsonRpcRequest, 4 | setJsonRpcResponse, 5 | INITIAL_STATE, 6 | setJsonRpcRequestFromHistory, 7 | } from './slice'; 8 | 9 | describe('jsonRpc', () => { 10 | describe('setJsonRpcRequest', () => { 11 | it('sets the request', () => { 12 | const result = reducer( 13 | INITIAL_STATE, 14 | setJsonRpcRequest({ origin: 'foo' }), 15 | ); 16 | 17 | expect(result.request).toStrictEqual({ origin: 'foo' }); 18 | }); 19 | 20 | it('pushes the request to history', () => { 21 | const result = reducer( 22 | INITIAL_STATE, 23 | setJsonRpcRequest({ origin: 'foo' }), 24 | ); 25 | 26 | expect(result.history).toStrictEqual([ 27 | { date: expect.any(Date), request: { origin: 'foo' } }, 28 | ]); 29 | }); 30 | }); 31 | 32 | describe('setJsonRpcRequestFromHistory', () => { 33 | it('sets the request', () => { 34 | const result = reducer( 35 | INITIAL_STATE, 36 | setJsonRpcRequestFromHistory({ origin: 'foo' }), 37 | ); 38 | 39 | expect(result.request).toStrictEqual({ origin: 'foo' }); 40 | }); 41 | 42 | it('does not push to history', () => { 43 | const result = reducer( 44 | INITIAL_STATE, 45 | setJsonRpcRequestFromHistory({ origin: 'foo' }), 46 | ); 47 | 48 | expect(result.history).toStrictEqual([]); 49 | }); 50 | }); 51 | 52 | describe('setJsonRpcResponse', () => { 53 | it('sets the response', () => { 54 | const result = reducer(INITIAL_STATE, setJsonRpcResponse('foo')); 55 | 56 | expect(result.response).toBe('foo'); 57 | }); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /src/features/handlers/json-rpc/slice.ts: -------------------------------------------------------------------------------- 1 | import { HandlerType } from '@metamask/snaps-utils'; 2 | import { JsonRpcRequest } from '@metamask/utils'; 3 | import { createSelector } from '@reduxjs/toolkit'; 4 | 5 | import { createHandlerSlice } from '../slice'; 6 | 7 | type Request = { 8 | origin: string; 9 | request?: JsonRpcRequest; 10 | }; 11 | 12 | type Response = string; 13 | 14 | export const INITIAL_STATE = { 15 | request: { 16 | origin: '', 17 | }, 18 | response: null, 19 | history: [], 20 | }; 21 | 22 | const slice = createHandlerSlice({ 23 | name: HandlerType.OnRpcRequest, 24 | initialState: INITIAL_STATE, 25 | }); 26 | 27 | export const jsonRpc = slice.reducer; 28 | export const { 29 | setRequest: setJsonRpcRequest, 30 | setRequestFromHistory: setJsonRpcRequestFromHistory, 31 | setResponse: setJsonRpcResponse, 32 | } = slice.actions; 33 | 34 | export const getJsonRpcRequest = createSelector( 35 | (state: { 36 | [HandlerType.OnRpcRequest]: ReturnType; 37 | }) => state[HandlerType.OnRpcRequest], 38 | (state) => state.request, 39 | ); 40 | -------------------------------------------------------------------------------- /src/features/handlers/slice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, Draft, PayloadAction } from '@reduxjs/toolkit'; 2 | 3 | export type HandlerSliceOptions = { 4 | name: string; 5 | initialState: HandlerState; 6 | }; 7 | 8 | export type HistoryEntry = { 9 | date: Date; 10 | request: Request; 11 | }; 12 | 13 | export type HandlerState = { 14 | request: Request; 15 | response: Response | null; 16 | history: HistoryEntry[]; 17 | pending?: boolean; 18 | }; 19 | 20 | /** 21 | * Create a slice for a handler. 22 | * 23 | * @param options - Options for the slice. 24 | * @param options.name - The name of the slice. 25 | * @param options.initialState - The initial state of the slice. 26 | * @returns The slice. 27 | */ 28 | export function createHandlerSlice({ 29 | name, 30 | initialState, 31 | }: HandlerSliceOptions) { 32 | const slice = createSlice({ 33 | name, 34 | initialState, 35 | reducers: { 36 | setRequest: (state, action: PayloadAction) => { 37 | // `immer` does not work well with generic types, so we have to cast. 38 | state.request = action.payload as Draft; 39 | state.history.push({ 40 | date: new Date(), 41 | request: action.payload as Draft, 42 | }); 43 | state.pending = true; 44 | }, 45 | setRequestFromHistory: (state, action: PayloadAction) => { 46 | // `immer` does not work well with generic types, so we have to cast. 47 | state.request = action.payload as Draft; 48 | }, 49 | setResponse: (state, action: PayloadAction) => { 50 | // `immer` does not work well with generic types, so we have to cast. 51 | state.response = action.payload as Draft; 52 | state.pending = false; 53 | }, 54 | }, 55 | }); 56 | 57 | return slice; 58 | } 59 | -------------------------------------------------------------------------------- /src/features/handlers/transactions/Transactions.test.tsx: -------------------------------------------------------------------------------- 1 | import { Transactions } from './Transactions'; 2 | 3 | describe('Transactions', () => { 4 | it('renders', () => { 5 | expect(() => ).not.toThrow(); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /src/features/handlers/transactions/Transactions.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent } from 'react'; 2 | 3 | import { Request } from './components'; 4 | 5 | export const Transactions: FunctionComponent = () => ; 6 | -------------------------------------------------------------------------------- /src/features/handlers/transactions/components/TransactionPrefill.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '../../../../utils'; 2 | import { TRANSACTION_PRESETS } from '../presets'; 3 | import { TransactionPrefill } from './TransactionPrefill'; 4 | 5 | describe('TransactionPrefill', () => { 6 | it('renders', () => { 7 | expect(() => 8 | render( 9 | , 15 | ), 16 | ).not.toThrow(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/features/handlers/transactions/components/TransactionPrefill.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent } from 'react'; 2 | 3 | import { Prefill } from '../../../../components'; 4 | import { TransactionFormData } from '../utils'; 5 | 6 | export type TransactionPrefillProps = TransactionFormData & { 7 | name: string; 8 | onClick: (prefill: TransactionFormData) => void; 9 | }; 10 | 11 | export const TransactionPrefill: FunctionComponent = ({ 12 | name, 13 | onClick, 14 | ...transaction 15 | }) => { 16 | const handleClick = () => { 17 | onClick(transaction); 18 | }; 19 | 20 | return ( 21 | 22 | {name} 23 | 24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /src/features/handlers/transactions/components/TransactionPrefills.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '../../../../utils'; 2 | import { TransactionPrefills } from './TransactionPrefills'; 3 | 4 | describe('TransactionPrefills', () => { 5 | it('renders', () => { 6 | expect(() => 7 | render(), 8 | ).not.toThrow(); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /src/features/handlers/transactions/components/TransactionPrefills.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Flex, Text } from '@chakra-ui/react'; 2 | import { FunctionComponent } from 'react'; 3 | 4 | import { TRANSACTION_PRESETS } from '../presets'; 5 | import { TransactionFormData } from '../utils'; 6 | import { TransactionPrefill } from './TransactionPrefill'; 7 | 8 | export type TransactionPrefillsProps = { 9 | onClick: (prefill: TransactionFormData) => void; 10 | }; 11 | 12 | export const TransactionPrefills: FunctionComponent< 13 | TransactionPrefillsProps 14 | > = ({ onClick }) => ( 15 | 16 | 17 | Transaction presets 18 | 19 | 20 | {TRANSACTION_PRESETS.map(({ name, transaction }, index) => ( 21 | 27 | ))} 28 | 29 | 30 | ); 31 | -------------------------------------------------------------------------------- /src/features/handlers/transactions/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Request'; 2 | export * from './TransactionPrefills'; 3 | -------------------------------------------------------------------------------- /src/features/handlers/transactions/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Transactions'; 2 | export * from './slice'; 3 | -------------------------------------------------------------------------------- /src/features/handlers/transactions/presets.ts: -------------------------------------------------------------------------------- 1 | import { TransactionFormData } from './utils'; 2 | 3 | export type TransactionPreset = { 4 | name: string; 5 | transaction: TransactionFormData; 6 | }; 7 | 8 | export const TRANSACTION_PRESETS: TransactionPreset[] = [ 9 | { 10 | name: 'ERC-20', 11 | transaction: { 12 | transactionOrigin: 'metamask.io', 13 | chainId: 'eip155:1', 14 | from: '0xa9d29f1acd75f93815f48011e2ac347ff164c7f4', 15 | to: '0x6b175474e89094c44da98b954eedeac495271d0f', 16 | value: '0', 17 | gas: '77727', 18 | nonce: '0', 19 | maxFeePerGas: '120', 20 | maxPriorityFeePerGas: '0.24', 21 | data: '0xa9059cbb000000000000000000000000dde3d2ed021aa02ff90110df1beb708894b4a4e9000000000000000000000000000000000000000000000002b5e3af16b1880000', 22 | }, 23 | }, 24 | { 25 | name: 'ERC-721', 26 | transaction: { 27 | transactionOrigin: 'metamask.io', 28 | chainId: 'eip155:1', 29 | from: '0x71ee35256e47ae7b80c4b986cb6923027f8bd8b8', 30 | to: '0xe42cad6fc883877a76a26a16ed92444ab177e306', 31 | value: '0', 32 | gas: '93439', 33 | nonce: '0', 34 | maxFeePerGas: '60.9', 35 | maxPriorityFeePerGas: '0.094', 36 | data: '0x23b872dd00000000000000000000000071ee35256e47ae7b80c4b986cb6923027f8bd8b80000000000000000000000009d311dd340fdcfefdb0b5742bd9012db4c9454cb000000000000000000000000000000000000000000000000000000000003989b', 37 | }, 38 | }, 39 | { 40 | name: 'ERC-1155', 41 | transaction: { 42 | transactionOrigin: 'metamask.io', 43 | chainId: 'eip155:1', 44 | from: '0x970b9713268ffa52b41021e8cc68e612cfcc43b4', 45 | to: '0x760c862191ebd06fe91ec76f7e8b7356308489e2', 46 | value: '0', 47 | gas: '56971', 48 | nonce: '0', 49 | maxFeePerGas: '15.65', 50 | maxPriorityFeePerGas: '0', 51 | data: '0xf242432a000000000000000000000000970b9713268ffa52b41021e8cc68e612cfcc43b4000000000000000000000000e828abd66d651cae1c1ba353995241fc3e5a336c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000', 52 | }, 53 | }, 54 | { 55 | name: 'Ether', 56 | transaction: { 57 | transactionOrigin: 'metamask.io', 58 | chainId: 'eip155:1', 59 | from: '0xaf50a1b549d2cc59f9d9cc405759096467bb0390', 60 | to: '0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', 61 | value: '0.003', 62 | gas: '21000', 63 | nonce: '0', 64 | maxFeePerGas: '32.22', 65 | maxPriorityFeePerGas: '2.6', 66 | data: '', 67 | }, 68 | }, 69 | ]; 70 | -------------------------------------------------------------------------------- /src/features/handlers/transactions/slice.ts: -------------------------------------------------------------------------------- 1 | import { HandlerType } from '@metamask/snaps-utils'; 2 | import { JsonRpcRequest } from '@metamask/utils'; 3 | import { createSelector } from '@reduxjs/toolkit'; 4 | 5 | import { createHandlerSlice } from '../slice'; 6 | 7 | type Request = { 8 | request?: JsonRpcRequest<{ 9 | chainId: string; 10 | transaction: Record; 11 | transactionOrigin?: string; 12 | }>; 13 | }; 14 | 15 | type Response = string; 16 | 17 | export const INITIAL_STATE = { 18 | request: {}, 19 | response: null, 20 | history: [], 21 | }; 22 | 23 | const slice = createHandlerSlice({ 24 | name: HandlerType.OnTransaction, 25 | initialState: INITIAL_STATE, 26 | }); 27 | 28 | export const transactions = slice.reducer; 29 | export const { 30 | setRequest: setTransactionRequest, 31 | setRequestFromHistory: setTransactionRequestFromHistory, 32 | setResponse: setTransactionResponse, 33 | } = slice.actions; 34 | 35 | export const getTransactionRequest = createSelector( 36 | (state: { 37 | [HandlerType.OnTransaction]: ReturnType; 38 | }) => state[HandlerType.OnTransaction], 39 | (state) => state.request, 40 | ); 41 | -------------------------------------------------------------------------------- /src/features/handlers/transactions/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { hexlifyTransactionData } from './utils'; 2 | 3 | describe('hexlifyTransactionData', () => { 4 | it('hexlifies decimals', () => { 5 | expect( 6 | hexlifyTransactionData({ 7 | from: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', 8 | to: '0x', 9 | value: '0.01', 10 | gas: '21000', 11 | nonce: '5', 12 | maxFeePerGas: '10', 13 | maxPriorityFeePerGas: '1', 14 | data: '0x', 15 | }), 16 | ).toStrictEqual({ 17 | data: '0x', 18 | from: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', 19 | gas: '0x5208', 20 | maxFeePerGas: '0x2540be400', 21 | maxPriorityFeePerGas: '0x3b9aca00', 22 | nonce: '0x5', 23 | to: '0x', 24 | value: '0x2386f26fc10000', 25 | }); 26 | }); 27 | 28 | it('supports hex', () => { 29 | expect( 30 | hexlifyTransactionData({ 31 | data: '0x', 32 | from: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', 33 | gas: '0x5208', 34 | maxFeePerGas: '0x2540be400', 35 | maxPriorityFeePerGas: '0x3b9aca00', 36 | nonce: '0x5', 37 | to: '0x', 38 | value: '0x2386f26fc10000', 39 | }), 40 | ).toStrictEqual({ 41 | data: '0x', 42 | from: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', 43 | gas: '0x5208', 44 | maxFeePerGas: '0x2540be400', 45 | maxPriorityFeePerGas: '0x3b9aca00', 46 | nonce: '0x5', 47 | to: '0x', 48 | value: '0x2386f26fc10000', 49 | }); 50 | }); 51 | 52 | it('returns 0x for empty fields', () => { 53 | expect( 54 | hexlifyTransactionData({ 55 | data: '', 56 | from: '', 57 | gas: '', 58 | maxFeePerGas: '', 59 | maxPriorityFeePerGas: '', 60 | nonce: '', 61 | to: '', 62 | value: '', 63 | }), 64 | ).toStrictEqual({ 65 | data: '0x', 66 | from: '0x', 67 | gas: '0x', 68 | maxFeePerGas: '0x', 69 | maxPriorityFeePerGas: '0x', 70 | nonce: '0x', 71 | to: '0x', 72 | value: '0x', 73 | }); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /src/features/handlers/transactions/utils.ts: -------------------------------------------------------------------------------- 1 | import { parseUnits } from '@ethersproject/units'; 2 | import { bigIntToHex, isStrictHexString } from '@metamask/utils'; 3 | 4 | export type TransactionFormData = { 5 | chainId: string; 6 | transactionOrigin: string; 7 | from: string; 8 | to: string; 9 | nonce: string; 10 | value: string; 11 | data: string; 12 | gas: string; 13 | maxFeePerGas: string; 14 | maxPriorityFeePerGas: string; 15 | }; 16 | 17 | const hexlify = (input: string, unit?: string) => { 18 | if (input.length === 0) { 19 | return '0x'; 20 | } 21 | 22 | if (isStrictHexString(input) || input === '0x') { 23 | return input; 24 | } 25 | 26 | if (unit) { 27 | const parsed = parseUnits(input, unit); 28 | return bigIntToHex(parsed.toBigInt()); 29 | } 30 | return bigIntToHex(BigInt(input)); 31 | }; 32 | 33 | export const hexlifyTransactionData = ( 34 | transaction: Omit, 35 | ) => { 36 | const gas = hexlify(transaction.gas); 37 | const nonce = hexlify(transaction.nonce); 38 | const maxFeePerGas = hexlify(transaction.maxFeePerGas, 'gwei'); 39 | const maxPriorityFeePerGas = hexlify( 40 | transaction.maxPriorityFeePerGas, 41 | 'gwei', 42 | ); 43 | const value = hexlify(transaction.value, 'ether'); 44 | const to = hexlify(transaction.to); 45 | const from = hexlify(transaction.from); 46 | const data = hexlify(transaction.data); 47 | return { 48 | ...transaction, 49 | to, 50 | from, 51 | data, 52 | gas, 53 | nonce, 54 | maxFeePerGas, 55 | maxPriorityFeePerGas, 56 | value, 57 | }; 58 | }; 59 | -------------------------------------------------------------------------------- /src/features/index.ts: -------------------------------------------------------------------------------- 1 | export * from './builder'; 2 | export * from './handlers'; 3 | export * from './layout'; 4 | export * from './manifest'; 5 | export * from './navigation'; 6 | export * from './notifications'; 7 | export * from './simulation'; 8 | export * from './configuration'; 9 | export * from './polling'; 10 | export * from './console'; 11 | -------------------------------------------------------------------------------- /src/features/layout/Layout.tsx: -------------------------------------------------------------------------------- 1 | import { Flex } from '@chakra-ui/react'; 2 | import { FunctionComponent, ReactNode } from 'react'; 3 | import { Outlet } from 'react-router-dom'; 4 | 5 | import { Console } from '../console'; 6 | import { Header, Sidebar } from './components'; 7 | 8 | type LayoutProps = { 9 | children?: ReactNode; 10 | }; 11 | 12 | /** 13 | * Render the layout of the application. 14 | * 15 | * @returns A React component. 16 | */ 17 | export const Layout: FunctionComponent = () => ( 18 | 19 |
20 | 21 | 22 | 23 | 24 | {/* Note: Due to the use of `react-router-dom`, we have to use the `Outlet` 25 | component here, rather than `children`. This renders the current active 26 | page. */} 27 | 28 | 29 | 30 | 31 | 32 | 33 | ); 34 | -------------------------------------------------------------------------------- /src/features/layout/components/Header.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '../../../utils'; 2 | import { Header } from './Header'; 3 | 4 | describe('Header', () => { 5 | it('renders', () => { 6 | expect(() => render(
)).not.toThrow(); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /src/features/layout/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import { Container, Stack } from '@chakra-ui/react'; 2 | import { FunctionComponent } from 'react'; 3 | 4 | import { Logo } from '../../../components'; 5 | import { Configuration } from '../../configuration'; 6 | import { StatusIndicator } from '../../status'; 7 | 8 | export const Header: FunctionComponent = () => ( 9 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | ); 26 | -------------------------------------------------------------------------------- /src/features/layout/components/Sidebar.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '../../../utils'; 2 | import { Sidebar } from './Sidebar'; 3 | 4 | describe('Sidebar', () => { 5 | it('renders', () => { 6 | expect(() => render()).not.toThrow(); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /src/features/layout/components/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | import { Flex } from '@chakra-ui/react'; 2 | import { FunctionComponent } from 'react'; 3 | 4 | import { Navigation } from '../../navigation'; 5 | import { Bottom } from '../../navigation/components'; 6 | 7 | /** 8 | * The sidebar component, which holds the navigation buttons, etc. 9 | * 10 | * @returns The sidebar component. 11 | */ 12 | export const Sidebar: FunctionComponent = () => ( 13 | 14 | 15 | 16 | 17 | ); 18 | -------------------------------------------------------------------------------- /src/features/layout/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Header'; 2 | export * from './Sidebar'; 3 | -------------------------------------------------------------------------------- /src/features/layout/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Layout'; 2 | -------------------------------------------------------------------------------- /src/features/manifest/Manifest.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '../../utils'; 2 | import { Manifest } from './Manifest'; 3 | 4 | jest.mock('react-monaco-editor'); 5 | 6 | describe('Manifest', () => { 7 | it('renders', () => { 8 | expect(() => render()).not.toThrow(); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /src/features/manifest/Manifest.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Flex, 4 | Tab, 5 | TabList, 6 | TabPanel, 7 | TabPanels, 8 | Tabs, 9 | } from '@chakra-ui/react'; 10 | import { FunctionComponent } from 'react'; 11 | 12 | import { Editor } from '../../components'; 13 | import { useSelector } from '../../hooks'; 14 | import { getSnapManifest } from '../simulation'; 15 | import { Validation } from './components'; 16 | 17 | /** 18 | * Manifest page, which displays the manifest of a snap, and any errors that 19 | * may be present. 20 | * 21 | * @returns The Manifest component. 22 | */ 23 | export const Manifest: FunctionComponent = () => { 24 | const manifest = useSelector(getSnapManifest); 25 | 26 | return ( 27 | 28 | 29 | 30 | 36 | 37 | Validation 38 | 39 | 45 | 51 | 52 | 53 | 54 | 55 | 56 | 64 | 70 | 71 | Manifest 72 | 73 | 79 | 85 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | ); 97 | }; 98 | -------------------------------------------------------------------------------- /src/features/manifest/components/Item.test.tsx: -------------------------------------------------------------------------------- 1 | import { List } from '@chakra-ui/react'; 2 | 3 | import { render } from '../../../utils'; 4 | import { Item } from './Item'; 5 | 6 | describe('Item', () => { 7 | it('renders', () => { 8 | expect(() => 9 | render( 10 | 11 | 12 | , 13 | ), 14 | ).not.toThrow(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/features/manifest/components/Validation.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '../../../utils'; 2 | import { Validation } from './Validation'; 3 | 4 | describe('Validation', () => { 5 | it('renders', () => { 6 | expect(() => render()).not.toThrow(); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /src/features/manifest/components/Validation.tsx: -------------------------------------------------------------------------------- 1 | import { Center, Heading, List, Text } from '@chakra-ui/react'; 2 | import { FunctionComponent } from 'react'; 3 | 4 | import { Icon } from '../../../components'; 5 | import { useSelector } from '../../../hooks'; 6 | import { getSnapManifest } from '../../simulation'; 7 | import { getManifestResults } from '../slice'; 8 | import { Item } from './Item'; 9 | 10 | export const Validation: FunctionComponent = () => { 11 | const manifest = useSelector(getSnapManifest); 12 | const results = useSelector(getManifestResults); 13 | 14 | if (!manifest) { 15 | return ( 16 |
21 | 22 | 29 | Manifest can't be found 30 | 31 | 32 | Make sure you’re connected to the snap 33 |
34 | and have a snap.manifest.json 35 |
36 | file in the root of your repository. 37 |
38 |
39 | ); 40 | } 41 | 42 | return ( 43 | 44 | {results.map(({ name, manifestName, isValid, message }) => ( 45 | 52 | ))} 53 | 54 | ); 55 | }; 56 | -------------------------------------------------------------------------------- /src/features/manifest/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Item'; 2 | export * from './Validation'; 3 | -------------------------------------------------------------------------------- /src/features/manifest/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Manifest'; 2 | export * from './sagas'; 3 | export * from './slice'; 4 | -------------------------------------------------------------------------------- /src/features/manifest/sagas.test.ts: -------------------------------------------------------------------------------- 1 | import { expectSaga } from 'redux-saga-test-plan'; 2 | 3 | import { MOCK_MANIFEST_FILE } from '../simulation/test/mockManifest'; 4 | import { 5 | MOCK_SNAP_ICON_FILE, 6 | MOCK_SNAP_SOURCE_FILE, 7 | } from '../simulation/test/mockSnap'; 8 | import { validationSaga } from './sagas'; 9 | import { 10 | ManifestStatus, 11 | setResults, 12 | setValid, 13 | validateManifest, 14 | } from './slice'; 15 | 16 | describe('validationSaga', () => { 17 | it('validates the manifest', async () => { 18 | await expectSaga(validationSaga, validateManifest(MOCK_MANIFEST_FILE)) 19 | .withState({ 20 | simulation: { 21 | sourceCode: MOCK_SNAP_SOURCE_FILE, 22 | icon: MOCK_SNAP_ICON_FILE, 23 | }, 24 | }) 25 | .call.like({ 26 | args: [ 27 | MOCK_MANIFEST_FILE, 28 | { 29 | sourceCode: MOCK_SNAP_SOURCE_FILE, 30 | icon: MOCK_SNAP_ICON_FILE, 31 | }, 32 | ], 33 | }) 34 | .put.actionType(setResults.type) 35 | .put(setValid(ManifestStatus.Invalid)) 36 | .silentRun(); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /src/features/manifest/sagas.ts: -------------------------------------------------------------------------------- 1 | import { SnapManifest, VirtualFile } from '@metamask/snaps-utils'; 2 | import { PayloadAction } from '@reduxjs/toolkit'; 3 | import { SagaIterator } from 'redux-saga'; 4 | import { all, call, put, select, takeLatest } from 'redux-saga/effects'; 5 | 6 | import { getIcon, getSourceCode } from '../simulation'; 7 | import { 8 | ManifestStatus, 9 | setResults, 10 | setValid, 11 | validateManifest, 12 | ValidationResult, 13 | } from './slice'; 14 | import { validators } from './validators'; 15 | 16 | /** 17 | * Validate the snap manifest. 18 | * 19 | * @param action - The action with the snap manifest. 20 | * @param action.payload - The snap manifest. 21 | * @yields Selects the state, calls the validators, and dispatches the results. 22 | */ 23 | export function* validationSaga({ 24 | payload: manifest, 25 | }: PayloadAction>): SagaIterator { 26 | const sourceCode: VirtualFile = yield select(getSourceCode); 27 | const icon: VirtualFile = yield select(getIcon); 28 | 29 | const results: ValidationResult[] = []; 30 | 31 | for (const { validator, name, manifestName } of validators) { 32 | const result = yield call(validator, manifest, { 33 | sourceCode, 34 | icon, 35 | }); 36 | 37 | const message = typeof result === 'string' ? result : undefined; 38 | const isValid = typeof result === 'boolean' ? result : false; 39 | 40 | results.push({ 41 | name, 42 | manifestName, 43 | isValid, 44 | message, 45 | }); 46 | } 47 | 48 | const isValid = results.every(({ isValid: valid }) => valid); 49 | const status = isValid ? ManifestStatus.Valid : ManifestStatus.Invalid; 50 | 51 | yield put(setResults(results)); 52 | yield put(setValid(status)); 53 | } 54 | 55 | /** 56 | * Root saga for the manifest feature. 57 | * 58 | * @yields The validation saga. 59 | */ 60 | export function* manifestSaga() { 61 | yield all([takeLatest(validateManifest, validationSaga)]); 62 | } 63 | -------------------------------------------------------------------------------- /src/features/manifest/slice.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | INITIAL_MANIFEST_STATE, 3 | manifest as reducer, 4 | ManifestStatus, 5 | setResults, 6 | setValid, 7 | } from './slice'; 8 | 9 | describe('manifest slice', () => { 10 | describe('setValid', () => { 11 | it('sets the status of the manifest', () => { 12 | const result = reducer( 13 | INITIAL_MANIFEST_STATE, 14 | setValid(ManifestStatus.Valid), 15 | ); 16 | 17 | expect(result.valid).toStrictEqual(ManifestStatus.Valid); 18 | }); 19 | }); 20 | 21 | describe('setResults', () => { 22 | it('sets the validation results', () => { 23 | const result = reducer( 24 | INITIAL_MANIFEST_STATE, 25 | setResults([ 26 | { 27 | name: 'foo', 28 | manifestName: 'bar', 29 | isValid: true, 30 | }, 31 | ]), 32 | ); 33 | 34 | expect(result.results).toStrictEqual([ 35 | { 36 | name: 'foo', 37 | manifestName: 'bar', 38 | isValid: true, 39 | }, 40 | ]); 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/features/manifest/slice.ts: -------------------------------------------------------------------------------- 1 | import { SnapManifest, VirtualFile } from '@metamask/snaps-utils'; 2 | import { 3 | createAction, 4 | createSelector, 5 | createSlice, 6 | PayloadAction, 7 | } from '@reduxjs/toolkit'; 8 | 9 | import { Validator } from './validators'; 10 | 11 | export enum ManifestStatus { 12 | Valid = 'valid', 13 | Invalid = 'invalid', 14 | Unknown = 'unknown', 15 | } 16 | 17 | export type ValidationResult = Omit & { 18 | isValid: boolean; 19 | message?: string | undefined; 20 | }; 21 | 22 | export type ManifestState = { 23 | valid: ManifestStatus; 24 | results: ValidationResult[]; 25 | }; 26 | 27 | export const INITIAL_MANIFEST_STATE: ManifestState = { 28 | valid: ManifestStatus.Unknown, 29 | results: [], 30 | }; 31 | 32 | const slice = createSlice({ 33 | name: 'manifest', 34 | initialState: INITIAL_MANIFEST_STATE, 35 | reducers: { 36 | setValid(state, action: PayloadAction) { 37 | state.valid = action.payload; 38 | }, 39 | setResults(state, action: PayloadAction) { 40 | state.results = action.payload; 41 | }, 42 | }, 43 | }); 44 | 45 | export const validateManifest = createAction>( 46 | `${slice.name}/validateManifest`, 47 | ); 48 | 49 | export const { setValid, setResults } = slice.actions; 50 | export const manifest = slice.reducer; 51 | 52 | export const getManifestStatus = createSelector( 53 | (state: { manifest: ManifestState }) => state.manifest, 54 | (state) => state.valid, 55 | ); 56 | 57 | export const getManifestResults = createSelector( 58 | (state: { manifest: ManifestState }) => state.manifest, 59 | (state) => state.results, 60 | ); 61 | -------------------------------------------------------------------------------- /src/features/navigation/Navigation.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '../../utils'; 2 | import { Navigation } from './Navigation'; 3 | 4 | describe('Navigation', () => { 5 | it('renders', () => { 6 | expect(() => render()).not.toThrow(); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /src/features/navigation/Navigation.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Container, List, Stack, Tag, Text } from '@chakra-ui/react'; 2 | import { FunctionComponent } from 'react'; 3 | 4 | import { Icon } from '../../components'; 5 | import { useSelector } from '../../hooks'; 6 | import { Item, ManifestStatusIndicator } from './components'; 7 | import { NAVIGATION_ITEMS } from './items'; 8 | 9 | /** 10 | * The navigation component, which holds the navigation buttons. 11 | * 12 | * @returns The navigation component. 13 | */ 14 | export const Navigation: FunctionComponent = () => { 15 | const applicationState = useSelector((state) => state); 16 | 17 | return ( 18 | 26 | 27 | {NAVIGATION_ITEMS.map( 28 | ({ condition, icon, label, tag, description, path }) => { 29 | if (condition && !condition(applicationState)) { 30 | return null; 31 | } 32 | 33 | return ( 34 | 35 | 36 | 37 | 38 | 39 | {label} 40 | {' '} 41 | 42 | {tag} 43 | 44 | 45 | 46 | {description} 47 | 48 | 49 | 50 | ); 51 | }, 52 | )} 53 | 54 | {/* For now we declare this separately, because it has special state. */} 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | Manifest 64 | {' '} 65 | 66 | snap.manifest.json 67 | 68 | 69 | 70 | Validate the snap manifest 71 | 72 | 73 | 74 | 75 | 76 | ); 77 | }; 78 | -------------------------------------------------------------------------------- /src/features/navigation/components/Bottom.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '../../../utils'; 2 | import { Bottom } from './Bottom'; 3 | 4 | describe('Bottom', () => { 5 | it('renders', () => { 6 | expect(() => render()).not.toThrow(); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /src/features/navigation/components/Bottom.tsx: -------------------------------------------------------------------------------- 1 | import { Box, List, Text } from '@chakra-ui/react'; 2 | import { FunctionComponent } from 'react'; 3 | 4 | import { Icon } from '../../../components'; 5 | import { useDispatch } from '../../../hooks'; 6 | import { openConfigurationModal } from '../../configuration'; 7 | import { Item } from './Item'; 8 | 9 | export const Bottom: FunctionComponent = () => { 10 | const dispatch = useDispatch(); 11 | 12 | const handleOpenConfiguration = () => { 13 | dispatch(openConfigurationModal()); 14 | }; 15 | 16 | return ( 17 | 18 | 22 | 23 | 24 | 25 | 26 | GitHub 27 | 28 | 29 | 30 | Report an issue or contribute to the project 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | Settings 40 | 41 | 42 | 43 | Configure the simulation environment 44 | 45 | 46 | 47 | 48 | ); 49 | }; 50 | -------------------------------------------------------------------------------- /src/features/navigation/components/Item.test.tsx: -------------------------------------------------------------------------------- 1 | import { List } from '@chakra-ui/react'; 2 | 3 | import { render } from '../../../utils'; 4 | import { Item } from './Item'; 5 | 6 | describe('Item', () => { 7 | it('renders', () => { 8 | expect(() => 9 | render( 10 | 11 | Bar 12 | , 13 | ), 14 | ).not.toThrow(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/features/navigation/components/Item.tsx: -------------------------------------------------------------------------------- 1 | import { ListItem, Stack } from '@chakra-ui/react'; 2 | import { FunctionComponent, ReactNode } from 'react'; 3 | import { useMatch } from 'react-router-dom'; 4 | 5 | import { Link } from '../../../components'; 6 | 7 | type ItemProps = { 8 | path: string; 9 | isExternal?: boolean; 10 | onClick?: () => void; 11 | children: ReactNode; 12 | }; 13 | 14 | export const Item: FunctionComponent = ({ 15 | path, 16 | isExternal = false, 17 | onClick, 18 | children, 19 | }) => { 20 | const active = useMatch(path); 21 | const isActive = Boolean(active); 22 | 23 | return ( 24 | 25 | 38 | 39 | {children} 40 | 41 | 42 | 43 | ); 44 | }; 45 | -------------------------------------------------------------------------------- /src/features/navigation/components/ManifestStatusIndicator.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from '@chakra-ui/react'; 2 | import { FunctionComponent } from 'react'; 3 | 4 | import { useSelector } from '../../../hooks'; 5 | import { getManifestStatus, ManifestStatus } from '../../manifest/slice'; 6 | 7 | const MANIFEST_COLORS = { 8 | [ManifestStatus.Valid]: 'success.default', 9 | [ManifestStatus.Invalid]: 'error.default', 10 | }; 11 | 12 | export const ManifestStatusIndicator: FunctionComponent = () => { 13 | const manifestStatus = useSelector(getManifestStatus); 14 | 15 | if (manifestStatus === ManifestStatus.Unknown) { 16 | return null; 17 | } 18 | 19 | return ( 20 | 29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /src/features/navigation/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Bottom'; 2 | export * from './Item'; 3 | export * from './ManifestStatusIndicator'; 4 | -------------------------------------------------------------------------------- /src/features/navigation/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Navigation'; 2 | -------------------------------------------------------------------------------- /src/features/navigation/items.ts: -------------------------------------------------------------------------------- 1 | import { HandlerType } from '@metamask/snaps-utils'; 2 | 3 | import { IconName } from '../../components'; 4 | import { ApplicationState } from '../../store'; 5 | 6 | type ConditionFunction = (state: ApplicationState) => boolean; 7 | 8 | export type NavigationItem = { 9 | label: string; 10 | tag: string; 11 | description: string; 12 | icon: IconName; 13 | path: string; 14 | 15 | /** 16 | * Conditionally render the navigation item. If not provided, the item will 17 | * always be rendered. 18 | */ 19 | condition?: ConditionFunction; 20 | }; 21 | 22 | export const NAVIGATION_ITEMS: NavigationItem[] = [ 23 | { 24 | label: 'JSON-RPC', 25 | tag: 'onRpcRequest', 26 | description: 'Send a JSON-RPC request to the snap', 27 | icon: 'jsonRpc', 28 | path: `/handler/${HandlerType.OnRpcRequest}`, 29 | }, 30 | { 31 | label: 'Cronjobs', 32 | tag: 'onCronjob', 33 | description: 'Schedule and run periodic actions', 34 | icon: 'cronjob', 35 | path: `/handler/${HandlerType.OnCronjob}`, 36 | }, 37 | { 38 | label: 'Transaction', 39 | tag: 'onTransaction', 40 | description: 'Send a transaction to the snap', 41 | icon: 'insights', 42 | path: `/handler/${HandlerType.OnTransaction}`, 43 | }, 44 | { 45 | label: 'UI Builder', 46 | tag: 'ui', 47 | description: 'Build a user interface for the snap', 48 | icon: 'ui', 49 | path: '/builder', 50 | }, 51 | ]; 52 | -------------------------------------------------------------------------------- /src/features/notifications/Notifications.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '../../utils'; 2 | import { Notifications } from './Notifications'; 3 | 4 | describe('Notifications', () => { 5 | it('renders', () => { 6 | expect(() => render()).not.toThrow(); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /src/features/notifications/Notifications.tsx: -------------------------------------------------------------------------------- 1 | import { useToast } from '@chakra-ui/react'; 2 | import { FunctionComponent, useEffect } from 'react'; 3 | 4 | import { useDispatch, useSelector } from '../../hooks'; 5 | import { getNotifications, removeNotification } from './slice'; 6 | 7 | export const Notifications: FunctionComponent = () => { 8 | const dispatch = useDispatch(); 9 | const notifications = useSelector(getNotifications); 10 | const toast = useToast(); 11 | 12 | useEffect(() => { 13 | for (const notification of notifications) { 14 | toast({ 15 | position: 'top', 16 | description: notification.message, 17 | }); 18 | 19 | dispatch(removeNotification(notification.id)); 20 | } 21 | }, [notifications]); 22 | 23 | return null; 24 | }; 25 | -------------------------------------------------------------------------------- /src/features/notifications/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Notifications'; 2 | export * from './slice'; 3 | -------------------------------------------------------------------------------- /src/features/notifications/slice.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | addNotification, 3 | INITIAL_NOTIFICATIONS_STATE, 4 | notifications as reducer, 5 | removeNotification, 6 | } from './slice'; 7 | 8 | describe('notifications slice', () => { 9 | describe('addNotification', () => { 10 | it('adds a notification to the state', () => { 11 | const result = reducer( 12 | INITIAL_NOTIFICATIONS_STATE, 13 | addNotification('Hello, world!'), 14 | ); 15 | 16 | expect(result.notifications).toHaveLength(1); 17 | expect(result.notifications).toStrictEqual([ 18 | { 19 | id: expect.any(String), 20 | message: 'Hello, world!', 21 | }, 22 | ]); 23 | }); 24 | }); 25 | 26 | describe('removeNotification', () => { 27 | it('removes a notification from the state', () => { 28 | const result = reducer( 29 | { 30 | notifications: [ 31 | { 32 | id: '1', 33 | message: 'Hello, world!', 34 | }, 35 | ], 36 | }, 37 | removeNotification('1'), 38 | ); 39 | 40 | expect(result.notifications).toStrictEqual([]); 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/features/notifications/slice.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createSelector, 3 | createSlice, 4 | nanoid, 5 | PayloadAction, 6 | } from '@reduxjs/toolkit'; 7 | 8 | export type Notification = { 9 | id: string; 10 | message: string; 11 | }; 12 | 13 | export type NotificationsState = { 14 | notifications: Notification[]; 15 | }; 16 | 17 | export const INITIAL_NOTIFICATIONS_STATE: NotificationsState = { 18 | notifications: [], 19 | }; 20 | 21 | const slice = createSlice({ 22 | name: 'notifications', 23 | initialState: INITIAL_NOTIFICATIONS_STATE, 24 | reducers: { 25 | /** 26 | * Add a notification to the state. 27 | * 28 | * @param state - The current state. 29 | * @param action - The action with the notification message as the payload. 30 | */ 31 | addNotification(state, action: PayloadAction) { 32 | state.notifications.push({ 33 | id: nanoid(), 34 | message: action.payload, 35 | }); 36 | }, 37 | 38 | /** 39 | * Remove a notification from the state. 40 | * 41 | * @param state - The current state. 42 | * @param action - The action with the notification ID to remove as the 43 | * payload. 44 | */ 45 | removeNotification(state, action: PayloadAction) { 46 | state.notifications = state.notifications.filter( 47 | (notification) => notification.id !== action.payload, 48 | ); 49 | }, 50 | }, 51 | }); 52 | 53 | export const { addNotification, removeNotification } = slice.actions; 54 | export const notifications = slice.reducer; 55 | 56 | export const getNotifications = createSelector( 57 | (state: { notifications: NotificationsState }) => state.notifications, 58 | (state) => state.notifications, 59 | ); 60 | -------------------------------------------------------------------------------- /src/features/polling/index.ts: -------------------------------------------------------------------------------- 1 | export * from './sagas'; 2 | -------------------------------------------------------------------------------- /src/features/polling/sagas.test.ts: -------------------------------------------------------------------------------- 1 | import fetchMock from 'jest-fetch-mock'; 2 | import { expectSaga } from 'redux-saga-test-plan'; 3 | 4 | import { setIcon, setManifest, setSourceCode } from '../simulation'; 5 | import { 6 | MOCK_MANIFEST, 7 | MOCK_MANIFEST_FILE, 8 | } from '../simulation/test/mockManifest'; 9 | import { 10 | MOCK_SNAP_ICON, 11 | MOCK_SNAP_ICON_FILE, 12 | MOCK_SNAP_SOURCE, 13 | MOCK_SNAP_SOURCE_FILE, 14 | } from '../simulation/test/mockSnap'; 15 | import { fetchingSaga, pollingSaga } from './sagas'; 16 | 17 | fetchMock.enableMocks(); 18 | 19 | describe('pollingSaga', () => { 20 | it('calls the fetching saga and delay', async () => { 21 | await expectSaga(pollingSaga) 22 | .withState({}) 23 | .call(fetchingSaga) 24 | .delay(500) 25 | .silentRun(); 26 | }); 27 | }); 28 | 29 | describe('fetchingSaga', () => { 30 | beforeEach(() => { 31 | fetchMock.resetMocks(); 32 | }); 33 | 34 | it('fetches the snap and updates manifest and source code', async () => { 35 | fetchMock.mockResponses( 36 | JSON.stringify(MOCK_MANIFEST), 37 | MOCK_SNAP_SOURCE, 38 | MOCK_SNAP_ICON, 39 | ); 40 | await expectSaga(fetchingSaga) 41 | .withState({ 42 | configuration: { 43 | snapUrl: 'http://localhost:8080', 44 | }, 45 | simulation: { 46 | manifest: null, 47 | }, 48 | }) 49 | .put(setManifest(MOCK_MANIFEST_FILE)) 50 | .put(setSourceCode(MOCK_SNAP_SOURCE_FILE)) 51 | .put(setIcon(MOCK_SNAP_ICON_FILE)) 52 | .silentRun(); 53 | }); 54 | 55 | it('doesnt update the source code if checksum matches cached checksum', async () => { 56 | fetchMock.mockResponses(JSON.stringify(MOCK_MANIFEST)); 57 | await expectSaga(fetchingSaga) 58 | .withState({ 59 | configuration: { 60 | snapUrl: 'http://localhost:8080', 61 | }, 62 | simulation: { 63 | manifest: MOCK_MANIFEST_FILE, 64 | }, 65 | }) 66 | .not.put.actionType(setManifest.type) 67 | .not.put.actionType(setSourceCode.type) 68 | .not.put.actionType(setIcon.type) 69 | .silentRun(); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /src/features/renderer/Renderer.tsx: -------------------------------------------------------------------------------- 1 | import { Component, NodeType } from '@metamask/snaps-ui'; 2 | import { FunctionComponent } from 'react'; 3 | 4 | import { Copyable, Panel, Text, Divider, Heading, Spinner } from './components'; 5 | 6 | export const components: Record< 7 | NodeType, 8 | FunctionComponent<{ id: string; node: unknown }> 9 | > = { 10 | [NodeType.Copyable]: Copyable, 11 | [NodeType.Divider]: Divider, 12 | [NodeType.Heading]: Heading, 13 | [NodeType.Panel]: Panel, 14 | [NodeType.Spinner]: Spinner, 15 | [NodeType.Text]: Text, 16 | }; 17 | 18 | type RendererProps = { 19 | node: Component; 20 | id?: string; 21 | }; 22 | 23 | /** 24 | * A UI renderer for Snaps UI. 25 | * 26 | * @param props - The component props. 27 | * @param props.node - The component to render. 28 | * @param props.id - The component ID, to be used as a prefix for component 29 | * keys. 30 | * @returns The renderer component. 31 | */ 32 | export const Renderer: FunctionComponent = ({ 33 | node, 34 | id = 'root', 35 | }) => { 36 | const ReactComponent = components[node.type]; 37 | 38 | if (!ReactComponent) { 39 | throw new Error(`Unknown component type: ${node.type}.`); 40 | } 41 | 42 | return ; 43 | }; 44 | -------------------------------------------------------------------------------- /src/features/renderer/components/Copyable.tsx: -------------------------------------------------------------------------------- 1 | import { Flex, Text } from '@chakra-ui/react'; 2 | import { isComponent } from '@metamask/snaps-ui'; 3 | import { assert } from '@metamask/utils'; 4 | import { FunctionComponent, useEffect, useState } from 'react'; 5 | 6 | import { Icon } from '../../../components'; 7 | 8 | export type CopyableProps = { 9 | id: string; 10 | node: unknown; 11 | }; 12 | 13 | export const Copyable: FunctionComponent = ({ node, id }) => { 14 | const [copied, setCopied] = useState(false); 15 | 16 | assert(isComponent(node), 'Expected value to be a valid UI component.'); 17 | assert( 18 | node.type === 'copyable', 19 | 'Expected value to be a copyable component.', 20 | ); 21 | 22 | const handleClick = () => { 23 | navigator.clipboard 24 | .writeText(node.value) 25 | .then(() => { 26 | setCopied(true); 27 | }) 28 | .catch((error) => { 29 | console.error(error); 30 | }); 31 | }; 32 | 33 | // eslint-disable-next-line consistent-return 34 | useEffect(() => { 35 | if (copied) { 36 | const timeout = setTimeout(() => { 37 | setCopied(false); 38 | }, 2000); 39 | 40 | return () => clearTimeout(timeout); 41 | } 42 | }, [copied]); 43 | 44 | return ( 45 | 54 | 60 | {node.value} 61 | 62 | 68 | 69 | ); 70 | }; 71 | -------------------------------------------------------------------------------- /src/features/renderer/components/Divider.tsx: -------------------------------------------------------------------------------- 1 | import { Divider as ChakraDivider } from '@chakra-ui/react'; 2 | import { FunctionComponent } from 'react'; 3 | 4 | export type DividerProps = { 5 | id: string; 6 | node: unknown; 7 | }; 8 | 9 | export const Divider: FunctionComponent = ({ id }) => ( 10 | 17 | ); 18 | -------------------------------------------------------------------------------- /src/features/renderer/components/Heading.tsx: -------------------------------------------------------------------------------- 1 | import { Heading as ChakraHeading } from '@chakra-ui/react'; 2 | import { isComponent } from '@metamask/snaps-ui'; 3 | import { assert } from '@metamask/utils'; 4 | import { FunctionComponent } from 'react'; 5 | 6 | export type HeadingProps = { 7 | id: string; 8 | node: unknown; 9 | }; 10 | 11 | export const Heading: FunctionComponent = ({ node, id }) => { 12 | assert(isComponent(node), 'Expected value to be a valid UI component.'); 13 | assert(node.type === 'heading', 'Expected value to be a heading component.'); 14 | 15 | return ( 16 | 23 | {node.value} 24 | 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /src/features/renderer/components/Panel.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from '@chakra-ui/react'; 2 | import { isComponent } from '@metamask/snaps-ui'; 3 | import { assert } from '@metamask/utils'; 4 | import { FunctionComponent } from 'react'; 5 | 6 | import { Renderer } from '../Renderer'; 7 | 8 | export type PanelProps = { 9 | id: string; 10 | node: unknown; 11 | }; 12 | 13 | export const Panel: FunctionComponent = ({ node, id }) => { 14 | assert(isComponent(node), 'Expected value to be a valid UI component.'); 15 | assert(node.type === 'panel', 'Expected value to be a panel component.'); 16 | 17 | return ( 18 | 19 | {node.children.map((child, index) => ( 20 | 25 | ))} 26 | 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /src/features/renderer/components/Spinner.tsx: -------------------------------------------------------------------------------- 1 | import { Spinner as ChakraSpinner } from '@chakra-ui/react'; 2 | import { FunctionComponent } from 'react'; 3 | 4 | export type SpinnerProps = { 5 | id: string; 6 | node: unknown; 7 | }; 8 | 9 | export const Spinner: FunctionComponent = ({ id }) => ( 10 | 11 | ); 12 | -------------------------------------------------------------------------------- /src/features/renderer/components/Text.tsx: -------------------------------------------------------------------------------- 1 | import { Text as ChakraText } from '@chakra-ui/react'; 2 | import { isComponent } from '@metamask/snaps-ui'; 3 | import { assert } from '@metamask/utils'; 4 | import { FunctionComponent } from 'react'; 5 | import ReactMarkdown from 'react-markdown'; 6 | 7 | export type TextProps = { 8 | id: string; 9 | node: unknown; 10 | }; 11 | 12 | export const Text: FunctionComponent = ({ node, id }) => { 13 | assert(isComponent(node), 'Expected value to be a valid UI component.'); 14 | assert(node.type === 'text', 'Expected value to be a text component.'); 15 | 16 | return ( 17 | ( 21 | 22 | {value} 23 | 24 | ), 25 | }} 26 | key={`${id}-text`} 27 | > 28 | {node.value} 29 | 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /src/features/renderer/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Copyable'; 2 | export * from './Divider'; 3 | export * from './Heading'; 4 | export * from './Panel'; 5 | export * from './Spinner'; 6 | export * from './Text'; 7 | -------------------------------------------------------------------------------- /src/features/renderer/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Renderer'; 2 | -------------------------------------------------------------------------------- /src/features/simulation/index.ts: -------------------------------------------------------------------------------- 1 | export * from './hooks'; 2 | export * from './sagas'; 3 | export * from './slice'; 4 | -------------------------------------------------------------------------------- /src/features/simulation/middleware.test.ts: -------------------------------------------------------------------------------- 1 | import { JsonRpcEngine } from 'json-rpc-engine'; 2 | 3 | import { createMiscMethodMiddleware } from './middleware'; 4 | 5 | describe('createMiscMethodMiddleware', () => { 6 | it('supports metamask_getProviderState', async () => { 7 | const engine = new JsonRpcEngine(); 8 | engine.push(createMiscMethodMiddleware()); 9 | 10 | const response = await engine.handle({ 11 | jsonrpc: '2.0', 12 | id: 1, 13 | method: 'metamask_getProviderState', 14 | params: [], 15 | }); 16 | 17 | expect(response).toStrictEqual({ 18 | id: 1, 19 | jsonrpc: '2.0', 20 | result: { 21 | accounts: [], 22 | chainId: '0x01', 23 | isUnlocked: true, 24 | networkVersion: '0x01', 25 | }, 26 | }); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /src/features/simulation/middleware.ts: -------------------------------------------------------------------------------- 1 | import { 2 | JsonRpcEngineEndCallback, 3 | JsonRpcEngineNextCallback, 4 | JsonRpcMiddleware, 5 | JsonRpcRequest, 6 | PendingJsonRpcResponse, 7 | } from 'json-rpc-engine'; 8 | 9 | export const methodHandlers = { 10 | // eslint-disable-next-line @typescript-eslint/naming-convention 11 | metamask_getProviderState: getProviderStateHandler, 12 | }; 13 | 14 | /** 15 | * A mock handler for metamask_getProviderState that always returns a specific hardcoded result. 16 | * 17 | * @param _request - Incoming JSON-RPC request, ignored for this specific handler. 18 | * @param response - The outgoing JSON-RPC response, modified to return the result. 19 | * @param _next - The json-rpc-engine middleware next handler. 20 | * @param end - The json-rpc-engine middleware end handler. 21 | */ 22 | async function getProviderStateHandler( 23 | _request: JsonRpcRequest, 24 | response: PendingJsonRpcResponse, 25 | _next: JsonRpcEngineNextCallback, 26 | end: JsonRpcEngineEndCallback, 27 | ) { 28 | // For now this will return a mocked result, this should probably match whatever network we are talking to 29 | response.result = { 30 | isUnlocked: true, 31 | chainId: '0x01', 32 | networkVersion: '0x01', 33 | accounts: [], 34 | }; 35 | return end(); 36 | } 37 | 38 | /** 39 | * Creates a middleware for handling misc RPC methods normally handled internally by the MM client. 40 | * 41 | * @returns Nothing. 42 | */ 43 | export function createMiscMethodMiddleware(): JsonRpcMiddleware< 44 | unknown, 45 | unknown 46 | > { 47 | // This should probably use createAsyncMiddleware 48 | // eslint-disable-next-line @typescript-eslint/no-misused-promises 49 | return async function methodMiddleware(request, response, next, end) { 50 | const handler = 51 | methodHandlers[request.method as keyof typeof methodHandlers]; 52 | if (handler) { 53 | try { 54 | // Implementations may or may not be async, so we must await them. 55 | return await handler(request, response, next, end); 56 | } catch (error: any) { 57 | console.error(error); 58 | return end(error); 59 | } 60 | } 61 | 62 | return next(); 63 | }; 64 | } 65 | -------------------------------------------------------------------------------- /src/features/simulation/slice.test.ts: -------------------------------------------------------------------------------- 1 | import { IframeExecutionService } from '@metamask/snaps-controllers'; 2 | import { VirtualFile } from '@metamask/snaps-utils'; 3 | 4 | import { 5 | SnapStatus, 6 | simulation as reducer, 7 | setExecutionService, 8 | setManifest, 9 | setSourceCode, 10 | setStatus, 11 | INITIAL_STATE, 12 | } from './slice'; 13 | import { MockExecutionService } from './test/mockExecutionService'; 14 | import { MOCK_MANIFEST, MOCK_MANIFEST_FILE } from './test/mockManifest'; 15 | 16 | describe('simulation slice', () => { 17 | describe('setStatus', () => { 18 | it('sets the snap status', () => { 19 | const result = reducer(INITIAL_STATE, setStatus(SnapStatus.Ok)); 20 | 21 | expect(result.status).toStrictEqual(SnapStatus.Ok); 22 | }); 23 | }); 24 | 25 | describe('setExecutionService', () => { 26 | it('sets execution service', () => { 27 | const executionService = 28 | new MockExecutionService() as unknown as IframeExecutionService; 29 | const result = reducer( 30 | INITIAL_STATE, 31 | setExecutionService(executionService), 32 | ); 33 | 34 | expect(result.executionService).toStrictEqual(executionService); 35 | }); 36 | }); 37 | 38 | describe('setSourceCode', () => { 39 | it('sets the source code', () => { 40 | const result = reducer( 41 | INITIAL_STATE, 42 | setSourceCode(new VirtualFile('foo')), 43 | ); 44 | 45 | expect(result.sourceCode?.value).toBe('foo'); 46 | }); 47 | }); 48 | 49 | describe('setManifest', () => { 50 | it('sets the manifest', () => { 51 | const result = reducer(INITIAL_STATE, setManifest(MOCK_MANIFEST_FILE)); 52 | 53 | expect(result.manifest?.result).toStrictEqual(MOCK_MANIFEST); 54 | }); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /src/features/simulation/test/mockExecutionService.ts: -------------------------------------------------------------------------------- 1 | export class MockExecutionService { 2 | terminateAllSnaps() { 3 | // no-op for now 4 | } 5 | 6 | executeSnap() { 7 | // no-op for now 8 | } 9 | 10 | handleRpcRequest() { 11 | return 'foobar'; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/features/simulation/test/mockManifest.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/naming-convention */ 2 | import { SnapManifest, VirtualFile } from '@metamask/snaps-utils'; 3 | import { SemVerVersion, stringToBytes } from '@metamask/utils'; 4 | 5 | export const MOCK_MANIFEST = { 6 | version: '1.0.0' as SemVerVersion, 7 | description: 'The test example snap!', 8 | proposedName: '@metamask/example-snap', 9 | repository: { 10 | type: 'git' as const, 11 | url: 'https://github.com/MetaMask/example-snap.git', 12 | }, 13 | source: { 14 | shasum: 'jf7x/ZXB+/oM+VFX0HThGDXf8aubOgD/RO0edGbDDqc=', 15 | location: { 16 | npm: { 17 | filePath: 'dist/bundle.js', 18 | packageName: '@metamask/example-snap', 19 | registry: 'https://registry.npmjs.org', 20 | iconPath: 'images/icon.svg', 21 | } as const, 22 | }, 23 | }, 24 | initialPermissions: { 25 | 'endowment:rpc': { snaps: false, dapps: true }, 26 | snap_getBip44Entropy: [ 27 | { 28 | coinType: 1, 29 | }, 30 | ], 31 | snap_dialog: {}, 32 | }, 33 | manifestVersion: '0.1' as const, 34 | }; 35 | 36 | export const MOCK_MANIFEST_FILE = new VirtualFile( 37 | stringToBytes(JSON.stringify(MOCK_MANIFEST)), 38 | ); 39 | MOCK_MANIFEST_FILE.result = MOCK_MANIFEST; 40 | MOCK_MANIFEST_FILE.path = 'snap.manifest.json'; 41 | MOCK_MANIFEST_FILE.data = { 42 | canonicalPath: `local:http://localhost:8080/${MOCK_MANIFEST_FILE.path}`, 43 | }; 44 | -------------------------------------------------------------------------------- /src/features/simulation/test/mockSnap.ts: -------------------------------------------------------------------------------- 1 | import { VirtualFile } from '@metamask/snaps-utils'; 2 | import { stringToBytes } from '@metamask/utils'; 3 | 4 | export const MOCK_SNAP_SOURCE = ` 5 | module.exports.onRpcRequest = ({ request }) => { 6 | console.log("Hello, world!"); 7 | 8 | const { method, id } = request; 9 | return method + id; 10 | }; 11 | `; 12 | 13 | export const MOCK_SNAP_SOURCE_FILE = new VirtualFile( 14 | stringToBytes(MOCK_SNAP_SOURCE), 15 | ); 16 | MOCK_SNAP_SOURCE_FILE.path = 'dist/bundle.js'; 17 | MOCK_SNAP_SOURCE_FILE.data = { 18 | canonicalPath: `local:http://localhost:8080/${MOCK_SNAP_SOURCE_FILE.path}`, 19 | }; 20 | 21 | export const MOCK_SNAP_ICON = 'foo'; 22 | 23 | export const MOCK_SNAP_ICON_FILE = new VirtualFile( 24 | stringToBytes(MOCK_SNAP_ICON), 25 | ); 26 | MOCK_SNAP_ICON_FILE.path = 'images/icon.svg'; 27 | MOCK_SNAP_ICON_FILE.data = { 28 | canonicalPath: `local:http://localhost:8080/${MOCK_SNAP_ICON_FILE.path}`, 29 | }; 30 | -------------------------------------------------------------------------------- /src/features/status/StatusIndicator.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '../../utils'; 2 | import { StatusIndicator } from './StatusIndicator'; 3 | 4 | describe('StatusIndicator', () => { 5 | it('renders', () => { 6 | expect(() => render()).not.toThrow(); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /src/features/status/StatusIndicator.tsx: -------------------------------------------------------------------------------- 1 | import { Flex, Spinner, Text } from '@chakra-ui/react'; 2 | import { useEffect, useState } from 'react'; 3 | 4 | import { Icon } from '../../components'; 5 | import { useDispatch, useSelector } from '../../hooks'; 6 | import { getSnapUrl, openConfigurationModal } from '../configuration'; 7 | import { SnapStatus, getStatus } from '../simulation'; 8 | 9 | export const StatusIndicator = () => { 10 | const snapUrl = useSelector(getSnapUrl); 11 | const status = useSelector(getStatus); 12 | const dispatch = useDispatch(); 13 | 14 | const [prettyUrl, setPrettyUrl] = useState(snapUrl); 15 | 16 | useEffect(() => { 17 | if (snapUrl) { 18 | try { 19 | const url = new URL(snapUrl); 20 | setPrettyUrl(url.host); 21 | } catch { 22 | // Ignore. 23 | } 24 | } 25 | }, [snapUrl]); 26 | 27 | const color = 28 | // eslint-disable-next-line no-nested-ternary 29 | status === SnapStatus.Ok 30 | ? 'text.success' 31 | : status === SnapStatus.Error 32 | ? 'text.error' 33 | : 'info.default'; 34 | 35 | const handleClick = () => { 36 | dispatch(openConfigurationModal()); 37 | }; 38 | 39 | return ( 40 | 51 | {status === SnapStatus.Ok && } 52 | {status === SnapStatus.Loading && } 53 | {status === SnapStatus.Error && ( 54 | 55 | )} 56 | 62 | {prettyUrl} 63 | 64 | 65 | ); 66 | }; 67 | -------------------------------------------------------------------------------- /src/features/status/index.ts: -------------------------------------------------------------------------------- 1 | export * from './StatusIndicator'; 2 | -------------------------------------------------------------------------------- /src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useDispatch'; 2 | export * from './useHandler'; 3 | export * from './useSelector'; 4 | -------------------------------------------------------------------------------- /src/hooks/useDispatch.ts: -------------------------------------------------------------------------------- 1 | import { useDispatch as useReduxDispatch } from 'react-redux'; 2 | 3 | import { Dispatch } from '../store/store'; 4 | 5 | /** 6 | * A hook to access the Redux dispatch function. 7 | * 8 | * This is a wrapper around the `useDispatch` hook from `react-redux`, to 9 | * provide a type-safe `Dispatch` type. 10 | * 11 | * @returns The Redux dispatch function. 12 | */ 13 | export const useDispatch: () => Dispatch = useReduxDispatch; 14 | -------------------------------------------------------------------------------- /src/hooks/useHandler.tsx: -------------------------------------------------------------------------------- 1 | import { HandlerType } from '@metamask/snaps-utils'; 2 | import { assert } from '@metamask/utils'; 3 | import { useMatch } from 'react-router-dom'; 4 | 5 | type SupportedHandler = 6 | | HandlerType.OnRpcRequest 7 | | HandlerType.OnCronjob 8 | | HandlerType.OnTransaction; 9 | 10 | /** 11 | * Get the handler ID from the current route. 12 | * 13 | * @returns The handler ID from the current route. 14 | */ 15 | export function useHandler(): SupportedHandler { 16 | const match = useMatch('/handler/:handlerId'); 17 | const handlerId = match?.params.handlerId; 18 | 19 | assert(handlerId, '`useHandler` must be used within a `Handler` component.'); 20 | 21 | return handlerId as SupportedHandler; 22 | } 23 | -------------------------------------------------------------------------------- /src/hooks/useSelector.ts: -------------------------------------------------------------------------------- 1 | import { 2 | TypedUseSelectorHook, 3 | useSelector as useReduxSelector, 4 | } from 'react-redux'; 5 | 6 | import { ApplicationState } from '../store/store'; 7 | 8 | /** 9 | * A hook to access the Redux store's state. 10 | * 11 | * This is a wrapper around the `useSelector` hook from `react-redux`, to 12 | * provide a type-safe `ApplicationState` type. 13 | * 14 | * @returns The Redux store's state. 15 | */ 16 | export const useSelector: TypedUseSelectorHook = 17 | useReduxSelector; 18 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Snaps Simulator 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import { assert } from '@metamask/utils'; 2 | import { createRoot } from 'react-dom/client'; 3 | 4 | import { App } from './App'; 5 | import { Root } from './components'; 6 | import { createStore } from './store'; 7 | 8 | // eslint-disable-next-line import/no-unassigned-import 9 | import './assets/fonts/fonts.css'; 10 | 11 | const rootElement = document.getElementById('root'); 12 | assert(rootElement, 'Root element not found.'); 13 | 14 | const store = createStore(); 15 | const root = createRoot(rootElement); 16 | 17 | root.render( 18 | 19 | 20 | , 21 | ); 22 | -------------------------------------------------------------------------------- /src/routes.tsx: -------------------------------------------------------------------------------- 1 | import { HandlerType } from '@metamask/snaps-utils'; 2 | import { 3 | createHashRouter, 4 | createRoutesFromElements, 5 | Navigate, 6 | Route, 7 | } from 'react-router-dom'; 8 | 9 | import { 10 | Cronjobs, 11 | JsonRpc, 12 | Layout, 13 | Transactions, 14 | Handler, 15 | Manifest, 16 | Builder, 17 | } from './features'; 18 | 19 | export const router = createHashRouter( 20 | createRoutesFromElements( 21 | }> 22 | 29 | } 30 | /> 31 | } /> 32 | } /> 33 | }> 34 | } 37 | /> 38 | } 41 | /> 42 | } 45 | /> 46 | 47 | , 48 | ), 49 | ); 50 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | export * from './store'; 2 | -------------------------------------------------------------------------------- /src/store/middleware.ts: -------------------------------------------------------------------------------- 1 | import createSagaMiddleware from 'redux-saga'; 2 | 3 | export const sagaMiddleware = createSagaMiddleware(); 4 | 5 | /** 6 | * A function to run a saga outside of the usual Redux flow. 7 | * 8 | * This is useful for running sagas, and waiting for a result using promises. 9 | * For example: 10 | * 11 | * ```ts 12 | * const result = await runSaga(mySaga, ...args).toPromise(); 13 | * ``` 14 | */ 15 | export const runSaga = sagaMiddleware.run.bind(sagaMiddleware); 16 | -------------------------------------------------------------------------------- /src/store/reducer.ts: -------------------------------------------------------------------------------- 1 | import { HandlerType } from '@metamask/snaps-utils'; 2 | import { combineReducers } from '@reduxjs/toolkit'; 3 | 4 | import { 5 | simulation, 6 | configuration, 7 | jsonRpc, 8 | cronjob, 9 | transactions, 10 | notifications, 11 | console, 12 | manifest, 13 | } from '../features'; 14 | 15 | export const reducer = combineReducers({ 16 | configuration, 17 | console, 18 | manifest, 19 | notifications, 20 | simulation, 21 | [HandlerType.OnRpcRequest]: jsonRpc, 22 | [HandlerType.OnCronjob]: cronjob, 23 | [HandlerType.OnTransaction]: transactions, 24 | }); 25 | -------------------------------------------------------------------------------- /src/store/sagas.ts: -------------------------------------------------------------------------------- 1 | import { all, fork } from 'redux-saga/effects'; 2 | 3 | import { simulationSaga, rootPollingSaga, manifestSaga } from '../features'; 4 | 5 | /** 6 | * Root saga for the application. 7 | * 8 | * @yields All sagas for the application. 9 | */ 10 | export function* rootSaga() { 11 | // To avoid one saga failing and crashing all sagas, we fork each saga 12 | // individually. 13 | yield all([fork(simulationSaga), fork(rootPollingSaga), fork(manifestSaga)]); 14 | } 15 | -------------------------------------------------------------------------------- /src/store/store.ts: -------------------------------------------------------------------------------- 1 | import { configureStore, createAction } from '@reduxjs/toolkit'; 2 | import { SagaIterator, Saga } from 'redux-saga'; 3 | import { cancel, fork, take } from 'redux-saga/effects'; 4 | 5 | import { sagaMiddleware } from './middleware'; 6 | import { reducer } from './reducer'; 7 | import { rootSaga } from './sagas'; 8 | 9 | export const abortSaga = createAction('ABORT_SAGA'); 10 | 11 | /** 12 | * Create an abortable saga. This is useful for hot module reloading, where 13 | * sagas need to be restarted. 14 | * 15 | * The saga will run until the {@link abortSaga} action is dispatched. 16 | * 17 | * @param saga - The saga to make abortable. 18 | * @returns An abortable saga. 19 | */ 20 | export function createAbortableSaga(saga: Saga) { 21 | return function* abortableSaga(): SagaIterator { 22 | const sagaTask = yield fork(saga); 23 | yield take(abortSaga.type); 24 | yield cancel(sagaTask); 25 | }; 26 | } 27 | 28 | /** 29 | * Create a Redux store. The store is configured to support hot reloading of 30 | * the reducers. 31 | * 32 | * @returns A Redux store. 33 | */ 34 | export function createStore() { 35 | const store = configureStore({ 36 | reducer, 37 | middleware: (getDefaultMiddleware) => 38 | getDefaultMiddleware({ 39 | thunk: false, 40 | immutableCheck: true, 41 | serializableCheck: false, 42 | }).concat(sagaMiddleware), 43 | }); 44 | 45 | sagaMiddleware.run(createAbortableSaga(rootSaga)); 46 | 47 | /* eslint-disable no-restricted-globals */ 48 | if (module.hot) { 49 | module.hot.accept('./reducer', () => store.replaceReducer(reducer)); 50 | module.hot.accept('./sagas', () => { 51 | store.dispatch(abortSaga()); 52 | sagaMiddleware.run(createAbortableSaga(rootSaga)); 53 | }); 54 | } 55 | /* eslint-enabled no-restricted-globals */ 56 | 57 | return store; 58 | } 59 | 60 | export type ApplicationState = ReturnType; 61 | export type Dispatch = ReturnType['dispatch']; 62 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/unambiguous 2 | declare module '*.svg'; 3 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './render'; 2 | -------------------------------------------------------------------------------- /src/utils/render.tsx: -------------------------------------------------------------------------------- 1 | import { render as testingLibraryRender } from '@testing-library/react'; 2 | import { ReactElement } from 'react'; 3 | import { MemoryRouter } from 'react-router-dom'; 4 | 5 | import { Root } from '../components'; 6 | import { createStore } from '../store'; 7 | 8 | /** 9 | * Render a component for testing. This function wraps the component in a 10 | * `Root` component, which provides the component with the same context that 11 | * it would have in the app. 12 | * 13 | * @param component - The component to render. 14 | * @param route - The route to render the component at. Defaults to `/`. 15 | * @returns The rendered component. 16 | */ 17 | export function render(component: ReactElement, route = '/') { 18 | const store = createStore(); 19 | return testingLibraryRender(component, { 20 | wrapper: ({ children }) => ( 21 | 22 | 23 | {children} 24 | 25 | 26 | ), 27 | }); 28 | } 29 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "inlineSources": true, 6 | "noEmit": false, 7 | "outDir": "dist", 8 | "rootDir": "src", 9 | "sourceMap": true 10 | }, 11 | "include": ["./src/**/*.ts"], 12 | "exclude": ["./src/**/*.test.ts"] 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "exactOptionalPropertyTypes": true, 5 | "forceConsistentCasingInFileNames": true, 6 | "lib": ["ES2020", "DOM"], 7 | "module": "CommonJS", 8 | "moduleResolution": "node", 9 | "noEmit": true, 10 | "noErrorTruncation": true, 11 | "noUncheckedIndexedAccess": true, 12 | "strict": true, 13 | "target": "es2020", 14 | "jsx": "react-jsx", 15 | "resolveJsonModule": true 16 | }, 17 | "exclude": ["node_modules", "./dist/**/*"] 18 | } 19 | -------------------------------------------------------------------------------- /webpack.config.ts: -------------------------------------------------------------------------------- 1 | import ReactRefreshPlugin from '@pmmmwh/react-refresh-webpack-plugin'; 2 | import FaviconsWebpackPlugin from 'favicons-webpack-plugin'; 3 | import ForkTsCheckerWebpackPlugin from 'fork-ts-checker-webpack-plugin'; 4 | import HtmlWebpackPlugin from 'html-webpack-plugin'; 5 | import MonacoEditorWebpackPlugin from 'monaco-editor-webpack-plugin'; 6 | import { Configuration, ProvidePlugin } from 'webpack'; 7 | import { Configuration as DevServerConfiguration } from 'webpack-dev-server'; 8 | import WebpackBarPlugin from 'webpackbar'; 9 | 10 | const config: Configuration & Record<'devServer', DevServerConfiguration> = { 11 | entry: './src/index.tsx', 12 | stats: 'errors-warnings', 13 | devtool: 'source-map', 14 | module: { 15 | rules: [ 16 | { 17 | test: /\.tsx?$/u, 18 | use: 'swc-loader', 19 | }, 20 | { 21 | test: /\.m?js$/u, 22 | include: /node_modules/u, 23 | type: 'javascript/auto', 24 | resolve: { 25 | fullySpecified: false, 26 | }, 27 | }, 28 | { 29 | test: /\.(png|jpe?g|gif|svg)$/u, 30 | type: 'asset', 31 | }, 32 | { 33 | test: /\.woff2?$/u, 34 | type: 'asset/resource', 35 | }, 36 | { 37 | test: /\.css$/u, 38 | use: ['style-loader', 'css-loader'], 39 | }, 40 | ], 41 | }, 42 | resolve: { 43 | extensions: ['.ts', '.tsx', '.js', '.jsx'], 44 | fallback: { 45 | assert: require.resolve('assert/'), 46 | constants: require.resolve('constants-browserify'), 47 | stream: require.resolve('stream-browserify'), 48 | // eslint-disable-next-line @typescript-eslint/naming-convention 49 | _stream_transform: require.resolve( 50 | 'readable-stream/lib/_stream_transform.js', 51 | ), 52 | util: false, 53 | }, 54 | }, 55 | plugins: [ 56 | new ProvidePlugin({ 57 | // These Node.js modules are used in some of the stream libs used 58 | process: 'process/browser', 59 | Buffer: ['buffer', 'Buffer'], 60 | }), 61 | new ReactRefreshPlugin(), 62 | new HtmlWebpackPlugin({ 63 | template: './src/index.html', 64 | }), 65 | new WebpackBarPlugin(), 66 | new ForkTsCheckerWebpackPlugin({ 67 | typescript: { 68 | diagnosticOptions: { 69 | semantic: true, 70 | syntactic: true, 71 | }, 72 | mode: 'write-references', 73 | }, 74 | }), 75 | new MonacoEditorWebpackPlugin({ 76 | languages: ['json', 'typescript'], 77 | features: ['bracketMatching', 'clipboard', 'hover', 'unicodeHighlighter'], 78 | }), 79 | new FaviconsWebpackPlugin('./src/assets/favicon.svg'), 80 | ], 81 | cache: { 82 | type: 'filesystem', 83 | buildDependencies: { 84 | config: [__filename], 85 | }, 86 | }, 87 | devServer: { 88 | port: 8000, 89 | historyApiFallback: true, 90 | }, 91 | }; 92 | 93 | export default config; 94 | --------------------------------------------------------------------------------