├── .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 |
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 |
46 | OK
47 |
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 |
49 | Cancel
50 |
51 |
52 | Approve
53 |
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 |
74 |
75 |
76 |
83 |
84 | Cancel
85 |
86 |
87 | Submit
88 |
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 |
62 | {state !== PlayButtonState.Loading && (
63 |
64 | )}
65 | {state === PlayButtonState.Loading && (
66 |
67 |
68 |
69 | )}
70 |
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 |
--------------------------------------------------------------------------------