commit c2913a65d9086a7b05b85b6f75b005301accd873 Author: Kurdistan Tech Ministry Date: Tue Jan 6 12:21:11 2026 +0300 Update domain references to pezkuwichain.app and rebrand from polkadot diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..3c229b5 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,10 @@ +root = true +[*] +indent_style=space +indent_size=2 +tab_width=2 +end_of_line=lf +charset=utf-8 +trim_trailing_whitespace=true +max_line_length=120 +insert_final_newline=true diff --git a/.github/issue_template.md b/.github/issue_template.md new file mode 100644 index 0000000..50f4014 --- /dev/null +++ b/.github/issue_template.md @@ -0,0 +1,86 @@ + + + +* **I'm submitting a ...** + + + + - [ ] Bug report + - [ ] Feature request + - [ ] Support request + - [ ] Other + + +* **What is the current behavior and expected behavior?** + + + + +* **What is the motivation for changing the behavior?** + + + + +* **Please tell us about your environment:** + + + + - Version: + - Environment: + + - [ ] Node.js + - [ ] Browser + - [ ] Other (limited support for other environments) + + - Language: + + - [ ] JavaScript + - [ ] TypeScript (include tsc --version) + - [ ] Other diff --git a/.github/workflows/auto-approve.yml b/.github/workflows/auto-approve.yml new file mode 100644 index 0000000..566790f --- /dev/null +++ b/.github/workflows/auto-approve.yml @@ -0,0 +1,16 @@ +name: bot + +on: + pull_request: + types: [labeled] + +jobs: + approve: + if: "! startsWith(github.event.head_commit.message, '[CI Skip]') && (!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository)" + runs-on: ubuntu-latest + steps: + - uses: jacogr/action-approve@795afd1dd096a2071d7ec98740661af4e853b7da + with: + authors: jacogr, TarikGul + labels: -auto + token: ${{ secrets.GH_PAT_BOT }} diff --git a/.github/workflows/auto-merge.yml b/.github/workflows/auto-merge.yml new file mode 100644 index 0000000..caf5da1 --- /dev/null +++ b/.github/workflows/auto-merge.yml @@ -0,0 +1,16 @@ +name: bot + +on: + pull_request: + types: [labeled] + +jobs: + merge: + runs-on: ubuntu-latest + steps: + - uses: jacogr/action-merge@d2d64b4545acd93b0a9575177d3d215ae3f92029 + with: + checks: pr (build),pr (lint),pr (test) + labels: -auto + strategy: squash + token: ${{ secrets.GH_PAT_BOT }} diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml new file mode 100644 index 0000000..381d0bc --- /dev/null +++ b/.github/workflows/lock.yml @@ -0,0 +1,23 @@ +name: 'Lock Threads' + +on: + schedule: + - cron: '20 2/3 * * *' + +jobs: + lock: + runs-on: ubuntu-latest + steps: + - uses: dessant/lock-threads@c1b35aecc5cdb1a34539d14196df55838bb2f836 + with: + github-token: ${{ secrets.GH_PAT_BOT }} + issue-inactive-days: '7' + issue-comment: > + This thread has been automatically locked since there has not been + any recent activity after it was closed. Please open a new issue + if you think you have a related problem or query. + pr-inactive-days: '2' + pr-comment: > + This pull request has been automatically locked since there + has not been any recent activity after it was closed. + Please open a new issue for related bugs. diff --git a/.github/workflows/pr-any.yml b/.github/workflows/pr-any.yml new file mode 100644 index 0000000..983e520 --- /dev/null +++ b/.github/workflows/pr-any.yml @@ -0,0 +1,22 @@ +name: PR +on: [pull_request] + +jobs: + pr: + continue-on-error: true + strategy: + matrix: + step: ['lint', 'test', 'build', diff] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 'lts/*' + - name: Set Execute Permissions + run: chmod +x ./scripts/* + - name: ${{ matrix.step }} + run: | + corepack enable + yarn install --immutable + yarn ${{ matrix.step }} diff --git a/.github/workflows/push-master.yml b/.github/workflows/push-master.yml new file mode 100644 index 0000000..8930837 --- /dev/null +++ b/.github/workflows/push-master.yml @@ -0,0 +1,32 @@ +name: Master +on: + push: + branches: + - master + +jobs: + master: + if: "! startsWith(github.event.head_commit.message, '[CI Skip]')" + strategy: + matrix: + step: ['build:release'] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GH_PAT_BOT }} + - uses: actions/setup-node@v4 + with: + node-version: 'lts/*' + - name: ${{ matrix.step }} + env: + CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }} + GH_PAT: ${{ secrets.GH_PAT_BOT }} + GH_RELEASE_GITHUB_API_TOKEN: ${{ secrets.GH_PAT_BOT }} + GH_RELEASE_FILES: master-ff-build.zip,master-ff-src.zip,master-chrome-build.zip,master-chrome-src.zip + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + corepack enable + yarn install --immutable + yarn ${{ matrix.step }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f12bbd9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +build/ +build-*/ +coverage/ +node_modules/ +tmp/ +NOTES.md +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local +.npmrc +.yarn/* +!.yarn/releases +!.yarn/plugins +.pnp.* +cc-test-reporter +lerna-debug.log* +master-chrome-build.zip +master-chrome-src.zip +master-ff-build.zip +master-ff-src.zip +npm-debug.log* +tsconfig.*buildinfo +yarn-debug.log* +yarn-error.log* +package-lock.json +_book +docs/html +.idea +.vscode +packages/extension/manifest.json + +# Diff script generates ff-diff +ff-diff \ No newline at end of file diff --git a/.mailmap b/.mailmap new file mode 100644 index 0000000..414c6ab --- /dev/null +++ b/.mailmap @@ -0,0 +1,3 @@ +Jaco +github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> +github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Github Actions diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/.npmignore @@ -0,0 +1 @@ + diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..2ef3430 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +18.14 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..c832fc9 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,3 @@ +build +coverage +packages diff --git a/.prettierrc.cjs b/.prettierrc.cjs new file mode 100644 index 0000000..21c4291 --- /dev/null +++ b/.prettierrc.cjs @@ -0,0 +1,4 @@ +// Copyright 2019-2025 @polkadot/extension authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +module.exports = require('@polkadot/dev/config/prettier.cjs'); diff --git a/.yarn/plugins/.keep b/.yarn/plugins/.keep new file mode 100644 index 0000000..e69de29 diff --git a/.yarnrc.yml b/.yarnrc.yml new file mode 100644 index 0000000..4b980e4 --- /dev/null +++ b/.yarnrc.yml @@ -0,0 +1,13 @@ +compressionLevel: mixed + +enableGlobalCache: false + +enableImmutableInstalls: false + +enableProgressBars: false + +logFilters: + - code: YN0013 + level: discard + +nodeLinker: node-modules diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c21552a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,1530 @@ +# CHANGELOG + +## 0.62.6 Nov 26, 2025 + +Changes: + +- Update polkadot-js dependecies ([#1603](https://github.com/polkadot-js/extension/pull/1603)) + + +## 0.62.5 Nov 17, 2025 + +Changes: + +- Add AHM warning page ([#1601](https://github.com/polkadot-js/extension/pull/1601)) + + +## 0.62.4 Nov 13, 2025 + +Changes: + +- Update polkadot-api and polkadot-js dependecies ([#1599](https://github.com/polkadot-js/extension/pull/1599)) + + +## 0.62.3 Oct 23, 2025 + +Changes: + +- Update polkadot-api and polkadot-js dependecies ([#1596](https://github.com/polkadot-js/extension/pull/1596)) + + +## 0.62.2 Oct 8, 2025 + +Changes: + +- Update @polkadot/phishing ([#1593](https://github.com/polkadot-js/extension/pull/1593)) + + +## 0.62.1 Sep 25, 2025 + +Changes: + +- Ethereum Ledger signing ([#1542](https://github.com/polkadot-js/extension/pull/1542)) +- Update polkadot-api, @polkadot/api, @polkadot/phishing ([#1591](https://github.com/polkadot-js/extension/pull/1591)) + + +## 0.61.7 Aug 28, 2025 + +Changes: + +- Update polkadot-api, @polkadot/common, @polkadot/api, @polkadot/ui, @polkadot/phishing ([#1588](https://github.com/polkadot-js/extension/pull/1588)) + + +## 0.61.6 Aug 15, 2025 + +Changes: + +- Update polkadot-api and polkadot-js ([#1582](https://github.com/polkadot-js/extension/pull/1582)) + + +## 0.61.5 July 30, 2025 + +Changes: + +- fix: signing extrinsics on ledger device ([#1579](https://github.com/polkadot-js/extension/pull/1579)) +- Update polkadot-api and polkadot-js ([#1580](https://github.com/polkadot-js/extension/pull/1580)) + + +## 0.61.4 July 16, 2025 + +Changes: + +- Update polkadot-api and polkadot-js ([#1575](https://github.com/polkadot-js/extension/pull/1575)) + + +## 0.61.3 July 8, 2025 + +Changes: + +- Revert #1560 ([#1573](https://github.com/polkadot-js/extension/pull/1573)) + + +## 0.61.2 July 4, 2025 + +Changes: + +- Update polkadot-js deps ([#1571](https://github.com/polkadot-js/extension/pull/1571)) + + +## 0.61.1 June 27, 2025 + +Changes: + +- Implement rate limiting for sign requests ([#1562](https://github.com/polkadot-js/extension/pull/1562)) +- Prevent phishing validation bypass via credentialed URLs using tldts ([#1560](https://github.com/polkadot-js/extension/pull/1560)) +- feat: add password strength validation ([#1561](https://github.com/polkadot-js/extension/pull/1561)) +- Fixed missing checkmark for connected accounts (website access) ([#1567](https://github.com/polkadot-js/extension/pull/1567)) + + +## 0.60.1 June 18, 2025 + +Changes: + +- Sanitize dApp origin ([#1554](https://github.com/polkadot-js/extension/pull/1554)) +- Enforce distinct authorization for HTTP and HTTPS origins ([#1555](https://github.com/polkadot-js/extension/pull/1555)) +- Use Map instead of object for storing authorized URLs ([#1556](https://github.com/polkadot-js/extension/pull/1556)) +- Update polkadot-js deps ([#1557](https://github.com/polkadot-js/extension/pull/1557)) +- Set yarn to 4.9.2 ([#1558](https://github.com/polkadot-js/extension/pull/1558)) + + +## 0.59.2 June 5, 2025 + +Changes: + +- Upgrade polkadot-api, and polkadot-js deps ([#1552](https://github.com/polkadot-js/extension/pull/1552)) + + +## 0.59.1 May 30, 2025 + +Changes: + +- Upgrade @polkadot/* deps ([#1550](https://github.com/polkadot-js/extension/pull/1550)) + ``` + @polkdot/api -> 16.0.1 + @polkadot/common -> 13.5.1 + @polkadot/phishing -> 0.25.11 + ``` + +## 0.58.10 May 14, 2025 + +Changes: + +- Upgrade @polkadot/deps ([#1539](https://github.com/polkadot-js/extension/pull/1539)) + + +## 0.58.9 May 5, 2025 + +Changes: + +- Upgrade @polkadot/phishing ([#1536](https://github.com/polkadot-js/extension/pull/1536)) + + +## 0.58.8 Apr 17, 2025 + +Changes: + +- Upgrade polkadot-js deps ([#1531](https://github.com/polkadot-js/extension/pull/1531)) + + +## 0.58.7 Apr 3, 2025 + +Changes: + +- Revamped Polkadot Extension Page with UI Enhancements ([#1527](https://github.com/polkadot-js/extension/pull/1527)) +- Upgrade polkadot-js deps ([#1528](https://github.com/polkadot-js/extension/pull/1528)) + + +## 0.58.6 Mar 21, 2025 + +Changes: + +- Upgrade polkadot-js deps ([#1521](https://github.com/polkadot-js/extension/pull/1521)) + - api to 15.8.1 + - phishing to 0.25.6 + + +## 0.58.5 Mar 5, 2025 + +Changes: + +- Ensure text is filtered so it doesnt cause white screens ([#1515](https://github.com/polkadot-js/extension/pull/1515)) +- Fix .name error with whitescreen ([#1516](https://github.com/polkadot-js/extension/pull/1516)) +- Upgrade polkadot-js deps ([#1517](https://github.com/polkadot-js/extension/pull/1517)) + + +## 0.58.4 Feb 19, 2025 + +Changes: + +- Upgrade polkadot-js deps ([#1512](https://github.com/polkadot-js/extension/pull/1512)) + + +## 0.58.3 Feb 6, 2025 + +Changes: + +- Upgrade polkadot-js api, and phishing ([#1504](https://github.com/polkadot-js/extension/pull/1504)) +- Upgrade @polkadot-api/merkleize-metadata to 1.1.13 ([#1505](https://github.com/polkadot-js/extension/pull/1505)) + + +## 0.58.2 Jan 23, 2025 + +Changes: + +- Upgrade polkadot-js api, and phishing ([#1501](https://github.com/polkadot-js/extension/pull/1501)) +- Bump dev to 0.83.2 ([#1500](https://github.com/polkadot-js/extension/pull/1500)) + + +## 0.58.1 Jan 8, 2025 + +Contributed: + +- Add support for canceling authorization requests without authorizing sites ([#1491](https://github.com/polkadot-js/extension/pull/1491)) (Thanks to https://github.com/F-OBrien) + +Changes: + +- Upgrade polkadot-js deps ([#1494](https://github.com/polkadot-js/extension/pull/1494)) ([#1498](https://github.com/polkadot-js/extension/pull/1498)) + - `@polkadot/api` -> 15.2.1 + - `@polkadot/common` -> 13.3.1 + - `@polkadot/phishing` -> 0.25.1 + - `@polkadot/ui` -> 3.12.1 +- Bump dev w/ @polkadot-api/merkelize-metadata ([#1495](https://github.com/polkadot-js/extension/pull/1495)) +- Bump @fortawesome/* deps ([#1496](https://github.com/polkadot-js/extension/pull/1496)) +- Bump yarn to 4.6.0 ([#1497](https://github.com/polkadot-js/extension/pull/1497)) +- Update headers to 2025 ([#1493](https://github.com/polkadot-js/extension/pull/1493)) + + +## 0.57.1 Dec 4, 2024 + +Breaking Changes: + +- Upgrade polkadot-js/api to 15.0.1 ([#1483](https://github.com/polkadot-js/extension/pull/1483)) + - This contains breaking changes where the api now allows the signer to alter the call data. Please reference [PR #6030](https://github.com/polkadot-js/api/pull/6030) for more information. +- Upgrade polkadot-js/phishing to 0.24.4 ([#1483](https://github.com/polkadot-js/extension/pull/1483)) + +Contributed: + +- fix: extension does not get injected on page load ([#1486](https://github.com/polkadot-js/extension/pull/1486)) + +Changes: + +- Upgrade @polkadot-api/merkleize-metadata to 1.1.10 ([#1484](https://github.com/polkadot-js/extension/pull/1484)) +- Bump yarn to 4.5.3 ([#1485](https://github.com/polkadot-js/extension/pull/1485)) + + +## 0.56.2 Nov 12, 2024 + +Changes: + +- Bump dev to 0.82.1 w/ tslib ([#1476](https://github.com/polkadot-js/extension/pull/1476)) +- Bump polkadot-js deps ([#1477](https://github.com/polkadot-js/extension/pull/1477)) + - polkadot/api -> 14.3.1 + - polkadot/common -> 13.2.3 + - polkadot/phishing -> 0.24.3 + - polkadot/ui -> 3.11.3 +- Bump typescript to 5.5.4 ([#1478](https://github.com/polkadot-js/extension/pull/1478)) + + +## 0.56.1 Oct 30, 2024 + +Changes: + +- Bump all polkadot deps ([#1473](https://github.com/polkadot-js/extension/pull/1473)) + - polkadot/api -> 14.2.1 + - polkadot/common -> 13.2.2 + - Gives support for Frequency, and Polimec + - polkadot/ui -> 3.11.2 + - polkadot/phishing -> 0.24.2 + + +## 0.55.1 Oct 24, 2024 + +Changes: + +- Bump dev to 0.81.2 ([#1469](https://github.com/polkadot-js/extension/pull/1469)) + - Export CJS and ESM correctly +- Bump all polkadot deps ([#1470](https://github.com/polkadot-js/extension/pull/1470)) + - polkadot/api -> 14.1.1 + - polkadot/common -> 13.2.1 + - polkadot/ui -> 3.11.1 + - polkadot/phishing -> 0.24.1 +- Bump yarn to 4.5.1 ([#1471](https://github.com/polkadot-js/extension/pull/1471)) + + +## 0.54.1 Oct 14, 2024 + +Changes: + +- Update polkadot-js deps ([#1466](https://github.com/polkadot-js/extension/pull/1466)) + - Polkadot-js api -> 14.0.1 + - NOTE: This adds support for Extrinsic V5. + - Polkadot-js phishing -> 0.23.7 +- Bump yarn to 4.5.0 ([#1467](https://github.com/polkadot-js/extension/pull/1467)) + + +## 0.53.1 Sep 24, 2024 + +Contributed: + +- Ability to Reject an authentication request instead of ignoring it ([#1453](https://github.com/polkadot-js/extension/pull/1453)) (Thanks to https://github.com/Tbaut) + +Changes: + +- Upgrade polkadot-js deps ([#1462](https://github.com/polkadot-js/extension/pull/1462)) + - This contains breaking changes in the API that was released in [13.0.1](https://github.com/polkadot-js/api/releases/tag/v13.0.1). The release changed the way AssetId is returned from `toPayload` in the Signer interface. `Option` is now returned as a SCALE encoded hex. + + +## 0.52.3 Aug 19, 2024 + +Changes: + +- Upgrade polkadot-js/api to 12.4.1 +- Upgrade polkadot-js/phishing to 0.23.4 + + +## 0.52.2 Aug 16, 2024 + +NOTE: This is strictly a patch release for the store. + +Changes: + +- Remove Alarm permissions ([#1449](https://github.com/polkadot-js/extension/pull/1449)) + + +## 0.52.1 Aug 14, 2024 + +Contributed: + +- Send ping before subscriptions (Thanks to https://github.com/F-OBrien) ([#1441](https://github.com/polkadot-js/extension/pull/1441)) +- Fix SignArea and ToastProvider timeout (Thanks to https://github.com/F-OBrien) ([#1444](https://github.com/polkadot-js/extension/pull/1444)) + +Changes: + +- Bump yarn to 4.4.0 ([#1442](https://github.com/polkadot-js/extension/pull/1442)) +- Enable "Chain Specific App" setting ([#1445](https://github.com/polkadot-js/extension/pull/1445)) + - This allows for ledger apps that are not included in the Polkadot Generic App to work with their specific Ledger App +- Fix setting rawMetadata as registry metadata ([#1446](https://github.com/polkadot-js/extension/pull/1446)) + + +## 0.51.1 Aug 7, 2024 + +Contributed: + +- Update XCM Analyser to v1.3.1 (Thanks to https://github.com/dudo50) ([#1419](https://github.com/polkadot-js/extension/pull/1419)) +- Fix: ensure the service worker is awake before every port message (Thanks to https://github.com/F-OBrien) ([#1433](https://github.com/polkadot-js/extension/pull/1433)) + - NOTE: The extension-base now exposes a set of functions for port connection stability. + - `setupPort` + - `wakeUpServiceWorker` + - `ensurePortConnection` + +Changes: + +- Bump yarn to 4.3.1 ([#1426](https://github.com/polkadot-js/extension/pull/1426)) +- Add CI script to check for diffs in src vs build for store release ([#1429](https://github.com/polkadot-js/extension/pull/1429)) ([#1436](https://github.com/polkadot-js/extension/pull/1436)) +- Change Connected to Connect Accounts ([#1430](https://github.com/polkadot-js/extension/pull/1430)) +- Upgrade Polkadot-js deps ([#1434](https://github.com/polkadot-js/extension/pull/1434)) ([#1435](https://github.com/polkadot-js/extension/pull/1435)) + - polkadot/api 12.3.1 + - polkadot/phishing 0.23.3 + - polkadot/ui 3.8.3 + + +## 0.50.1 July 30, 2024 + +Contributed: + +- Update subscribed accounts when connected site authorizations are modified (Thanks to https://github.com/F-OBrien) + - Deprecates `public udateCurrentTabsUrl` in `class State` in favor of `public updateCurrentTabsUrl`. + +Changes: + +- Add support for the Ledger Generic App (Thanks to https://github.com/bee344) +- Add support for the Ledger Migration App (Thanks to https://github.com/bee344) + - Note: In order to use the ledger migration app, you must toggle the setting inside of settings. That will enable the migration app for use. +- Fix extension stuck in ... loading ... screen after service_worker got terminated (Thanks to https://github.com/bee344) + + +## 0.49.3 July 19, 2024 + +Changes: + +- Fix ID used in manifest_firefox.json by adding brackets + - The previous patch required brackets arount the ID... + + +## 0.49.2 July 18, 2024 + +Changes: + +- Fix ID used in manifest_firefox.json + - This is internal, and is only necessary for publishing to the store + + +## 0.49.1 July 15, 2024 + +Breaking Changes: + +- Update from Manifest v2 to v3 for Chrome +- Update from Manifest v2 to v3 for Firefox + +Note: These are very large breaking changes. Please review the following PR's to see exactly what has changed and for any additional information that can assist you in your migration. + +([#1367](https://github.com/polkadot-js/extension/pull/1367)) +([#1388](https://github.com/polkadot-js/extension/pull/1388)) +([#1399](https://github.com/polkadot-js/extension/pull/1399)) + +Changes: + +- Update xcm analyzer to 1.3.0 +- Upgrade Polkadot.js Deps + - @polkadot/common -> 13.0.2 (Introduces the interface for the new ledger app. This will be implemented in the next release) + - @polkadot/api -> 12.2.1 + - @polkadot/phishing -> 0.23.1 + - @polkadot/ui -> 3.7.1 +- Update module resolution to bundler +- Clean the manifest build process + + +## 0.48.2 July 3, 2024 + +Contributed: + +- Fix: forget account for legacy account without authorizedAccounts (Thanks to https://github.com/Tbaut) + +Changes: + +- Adjust ui imports for deterministic bundling + + +## 0.48.1 June 27, 2024 + +- **Important** Not published to the stores, aligns with latest released packages. + +Changes: + +- Bump @polkadot/api to 12.0.2 + - NOTE: We are doing a minor bump because the api in this version now gives the option + to modify payloads for `signAndSend`, `signAsync`, and `dryRun` which the extension does not use. That being said, for any user that digests that package it will be available to use as a feature. +- Bump @polkadot/phishing to 0.22.10 + + +## 0.47.6 June 18, 2024 + +Changes: + +- Bump @polkadot/api to 11.3.1 +- Bump @polkadot/phishing to 0.22.9 +- Update build process to enable review by Firefox store + - Adds `corepack enable` to CI process + - Removes hardcoded path to `.yarn/release` in .yarnrc.yml + - Updates zip script to ensure correct compression + + +## 0.47.5 May 22, 2024 + +- **Important** Published only to Chrome store. + +Changes: + +- Bump @polkadot/api to 11.1.1 +- Bump @polkadot/phishing to 0.22.8 +- Bump @polkadot/dev to 0.79.3 + + +## 0.47.4 May 8, 2024 + +- **Important** Not published to the stores, aligns with latest released packages. + +Changes: + +- Bump polkadot/api to 11.0.3 and @polkadot/phishing to 0.22.7 + + +## 0.47.3 Apr 27, 2024 + +- **Important** Not published to the stores, aligns with latest released packages. + +Changes: + +- Bump polkadot/api to 11.0.2 + + +## 0.47.2 Apr 23, 2024 + +- **Important** Not published to the stores, aligns with latest released packages. + +Contributed: + +- chore: upgrade web3 dep (Thanks to https://github.com/gdethier) + +Changes: + +- Update polkadot/api and polkadot/phishing + + +## 0.47.1 Apr 18, 2024 + +- **Important** Not published to the stores, aligns with latest released packages. + +Contributed: + +- feat(extrinsic-ui): extrinsic asset id (Thanks to https://github.com/ryanleecode) +- feat: display asset id in xcm format (Thanks to https://github.com/ryanleecode) + +Changes: + +- Update nvmrc version +- Bump yarn to 4.1.1 +- Update the README with library notice +- Fix typos +- Update CI checkout and setup_node to v4 +- Update polkadot/* deps + + +## 0.46.9 Mar 20, 2024 + +- **Important** Not published to the stores, aligns with latest released packages. + +Contributed: + +- Fix: Prevent authorization request from incorrect origin due to chrome pre-rendering fixes (Thanks to https://github.com/F-OBrien) + +Changes: + +- Upgrade to `@polkadot/api` 10.12.4 +- Upgrade to `@polkadot/phishing` 0.22.4 + + +## 0.46.8 Mar 13, 2024 + +- **Important** Not published to the stores, aligns with latest released packages. + +Changes: + +- Upgrade to `@polkadot/api` 10.12.2 +- Upgrade to `@polkadot/phishing` 0.22.3 + + +## 0.46.7 Feb 28, 2024 + +- **Important** Not published to the stores, aligns with latest released packages. + +Changes: + +- Upgrade to `@polkadot/api` 10.11.2 +- Upgrade to `@polkadot/ui` 3.6.5 +- Upgrade to `@polkadot/phishing` 0.22.2 + + +## 0.46.6 Nov 18, 2023 + +- **Important** Not published to the stores, aligns with latest released packages. + +Changes: + +- Upgrade to `@polkadot/util` 12.6.1 +- Upgrade to `@polkadot/api` 10.11.1 + + +## 0.46.5 Jun 12, 2023 + +- **Important** Not published to the stores, aligns with latest released packages. + +Changes: + +- Adjust object index access for stricter tsconfig settings +- Upgrade to `@polkadot/api` 10.9.1 +- Upgrade to `@polkadot/util` 12.3.2 + + +## 0.46.4 Jun 5, 2023 + +- **Important** Not published to the stores, aligns with latest released packages. + +Changes: + +- Upgrade to `@polkadot/api` 10.8.1 +- Upgrade to `@polkadot/util` 12.2.2 + + +## 0.46.3 May 13, 2023 + +- **Important** Not published to the stores, aligns with latest released packages. + +Changes: + +- Add `module` to `package.json` export map (ESM-only) +- Upgrade to `@polkadot/api` 10.6.1 +- Upgrade to `@polkadot/util` 12.1.1 + + +## 0.46.2 Apr 30, 2023 + +- **Important** Not published to the stores, aligns with latest released packages. + +Changes: + +- Adjust compilation output for `__internal__` class fields +- Upgrade to `@polkadot/api` 10.5.1 +- Upgrade to `@polkadot/util` 12.1.1 + + +## 0.46.1 Apr 22, 2023 + +- **Important** Not published to the stores, aligns with latest released packages. + +Changes: + +- Upgrade to `@polkadot/api` 10.4.1 +- Upgrade to `@polkadot/util` 12.0.1 + + +## 0.45.5 Apr 1, 2023 + +- **Important** Not published to the stores, aligns with latest released packages. + +Changes: + +- Upgrade to `@polkadot/api` 10.2.2 +- Upgrade to `@polkadot/util` 11.1.3 + + +## 0.45.4 Mar 25, 2023 + +- **Important** Not published to the stores, aligns with latest released packages. + +Changes: + +- Updated to `@polkadot/api` 10.2.1 +- Updated to `@polkadot/util` 11.1.2 + + +## 0.45.3 Mar 19, 2023 + +- **Important** Not published to the stores, aligns with latest released packages. + +Changes: + +- Updated to `@polkadot/api` 10.1.4 +- Updated to `@polkadot/util` 11.1.1 + + +## 0.45.2 Mar 11, 2023 + +- **Important** Not published to the stores, aligns with latest released packages. + +Changes: + +- Use consistent `.js` imports in source files (TS moduleResolution) +- Updated to `@polkadot/api` 10.1.1 +- Updated to `@polkadot/util` 11.0.2 + + +## 0.45.1 Mar 5, 2023 + +- **Important** Not published to the stores, aligns with latest released packages. + +Changes: + +- Swap TS -> JS compiler to use tsc (from babel) +- Adjust all tests to use `node:test` runner (ESM variants) +- Updated to `@polkadot/api` 10.0.1 +- Updated to `@polkadot/util` 11.0.1 + + +## 0.44.9 Feb 19, 2023 + +- **Important** Not published to the stores, aligns with latest released packages. + +Changes: + +- Updated to `@polkadot/api` 9.14.2 +- Updated to `@polkadot/util` 10.4.2 + + +## 0.44.8 Jan 8, 2023 + +- **Important** Not published to the stores, aligns with latest released packages. + +Changes: + +- Updated to `@polkadot/api` 9.11.1 +- Updated to `@polkadot/util` 10.2.3 + + +## 0.44.7 Dec 27, 2022 + +- **Important** Not published to the stores, aligns with latest released packages. + +Contributed: + +- Fix naming of `getAllMetadata` message (Thanks to https://github.com/Nick-1979) +- Add more zh translations (Thanks to https://github.com/chendatony31) +- Typo fix (Thanks to https://github.com/Nick-1979) +- Re-add QR signing support (Thanks to https://github.com/Tbaut) +- Adjust flow of auth management screens (Thanks to https://github.com/Tbaut) +- Add links to connected accounts in header (Thanks to https://github.com/Tbaut) + +Changes: + +- Ensure that `EXTENSION_PREFIX` is always set as part of `@polkadot/extension-base` +- Allow for `genesisHash` filter to both `web3{Accounts, AccountsSubscribe}` +- Allow for transparent extension `ping` (as available) +- Support for new privacy-preserving `connect()` interfaces (non-default) +- Always set metadata before signing (fixes for ETH-compat chains) +- Updated to `@polkadot/api` 9.10.4 +- Updated to `@polkadot/util` 10.2.1 + + +## 0.44.6 Aug 21, 2022 + +- **Important** Not published to the stores, aligns with latest released packages. + +Changes: + +- Swap to using webpack from `@polkadot/dev` +- Upgrade to `@polkadot/api` 9.2.3 +- Updated to `@polkadot/util` 10.1.5 + + +## 0.44.5 Aug 13, 2022 + +- **Important** Not published to the stores, aligns with latest released packages. + +Changes: + +- Upgrade to `@polkadot/api` 9.2.1 +- Updated to `@polkadot/util` 10.1.4 + + +## 0.44.4 Aug 8, 2022 + +- **Important** Not published to the stores, aligns with latest released packages. + +Contributed: + +- Adjust layout for authorization modal (Thanks to https://github.com/Tbaut) + +Changes: + +- Upgrade to `@polkadot/api` 9.1.1 +- Updated to `@polkadot/util` 10.1.3 + + +## 0.44.3 Jul 30, 2022 + +- **Important** Not published to the stores, aligns with latest released packages. + +Contributed: + +- Adjust layout for authorization modal (Thanks to https://github.com/Tbaut) + +Changes: + +- Upgrade to `@polkadot/api` 9.0.1 +- Updated to `@polkadot/util` 10.1.2 + + +## 0.44.2 Jul 24, 2022 + +- **Important** Not published to the stores, aligns with latest released packages. + +Contributed: + +- Account selection/management for authorized sites (Thanks to https://github.com/Tbaut) +- UI icon & cursor adjustments for authorization (Thanks to https://github.com/roiLeo) + +Changes: + +- Allow for unsubscribe handling on account subscriptions +- Add error handling for user-supplied account callbacks +- Upgrade to `@polkadot/api` 8.14.1 +- Updated to `@polkadot/util` 10.1.1 + + +## 0.44.1 Jun 6, 2022 + +**Important** CHANGELOG entries are a rollup of details since last publish to the stores + +Contributed: + +- Adjust nvm version (Thanks to https://github.com/pedroapfilho) +- Add filtered account subscriptions (Thanks to https://github.com/hamidra) +- Display signed data as Ascii (Thanks to https://github.com/hamidra) +- Removal for authorized URLs (Thanks to https://github.com/Are10) +- Fix typo on https://js.pezkuwichain.app/docs/ (Thanks to https://github.com/michaelhealyco) + +Changes: + +- Remove all signing via QR (imcompatible) +- Swap to React 18 +- Gracefully handle promise rejections +- Don't apply shims on content pages, only apply on background +- Ensure that only latest metadata is applied (when multiple genesis) +- Rename all `*.test.ts` to `*.spec.ts` (cross-repo consistency) +- Only apply cross-browser environment globally in non-content scripts +- Ensure package path is available under ESM & CJS +- Upgrade to `@polkadot/api` 8.7.1 +- Updated to `@polkadot/util` 9.4.1 + + +## 0.43.3 Jun 4, 2022 + +- **Important** Not published to the stores, aligns with latest released packages. + +Contributed: + +- Adjust nvm version (Thanks to https://github.com/pedroapfilho) +- Add filtered account subscriptions (Thanks to https://github.com/hamidra) + +Changes: + +- Gracefully handle promise rejections +- Upgrade to `@polkadot/api` 8.7.1 +- Updated to `@polkadot/util` 9.4.1 + + +## 0.43.2 May 15, 2022 + +- **Important** Not published to the stores, aligns with latest released packages. + +Contributed: + +- Display signed data as Ascii (Thanks to https://github.com/hamidra) + +Changes: + +- Remove all signing via QR (imcompatible) +- Swap to React 18 +- Upgrade to `@polkadot/api` 8.4.1 +- Updated to `@polkadot/util` 9.2.1 + + +## 0.43.1 Apr 11, 2022 + +- **Important** Not published to the stores, aligns with latest released packages. + +- **Breaking change** In this version the commonjs outputs are moved to a sub-folder. Since the export map and main field in package.json does reflect this change, there should be no usage changes. However the packages here will all need to be on the same version for internal linkage. + +Changes: + +- Output commonjs files under the `cjs/**` root +- Upgrade to `@polkadot/api` 8.0.1 +- Updated to `@polkadot/util` 9.0.1 + + +## 0.42.10 Apr 4, 2022 + +**Important** Not published to the stores, aligns with latest released packages. + +Contributed: + +- Removal for authorized URLs (Thanks to https://github.com/Are10) + +Changes: + +- Adjust for bundlers where `import.meta.url` is undefined +- Bump `@polkadot/api` to 7.15.1 +- Bump `@polkadot/util` to 8.7.1 + + +## 0.42.9 Mar 14, 2022 + +**Important** Not published to the stores, aligns with latest released packages. + +Changes: + +- Adjust for bundlers where `import.meta.url` is undefined +- Bump `@polkadot/api` to 7.12.1 +- Bump `@polkadot/util` to 8.5.1 + + +## 0.42.7 Jan 23, 2022 + +**Important** Not published to the stores, aligns with latest released packages. + +Changes: + +- Bump `@polkadot/util` to 8.3.3 +- Bump `@polkadot/api` to 7.5.1 + + +## 0.42.6 Jan 17, 2022 + +**Important** Not published to the stores, aligns with latest released packages. + +Changes: + +- Don't apply shims on content pages, only apply on background +- Bump `@polkadot/util` to 8.3.2 +- Bump `@polkadot/api` to 7.4.1 + + +## 0.42.5 Jan 10, 2022 + +**Important** Not published to the stores, aligns with latest released packages. + +Changes: + +- Ensure that only latest metadata is applied (when multiple genesis) +- Rename all `*.test.ts` to `*.spec.ts` (cross-repo consistency) +- Only apply cross-browser environment globally in non-content scripts +- Ensure package path is available under ESM & CJS +- Bump `@polkadot/util` to 8.3.1 +- Bump `@polkadot/api` to 7.3.1 + + +## 0.42.4 Dec 27, 2021 + +**Important** As 0.42.3, not published to the stores, fixes dependency issue in 0.42.4. + +Changes: + +- Ensure `@polkadot/extension-mocks` is correctly listed as devDependency + + +## 0.42.3 Dec 27, 2021 + +**Important** Not published to the stores, aligns with latest released packages. + +Contributed: + +- Fix typo on https://js.pezkuwichain.app/docs/ (Thanks to https://github.com/michaelhealyco) + +Changes: + +- Bump `@polkadot/util` to 8.2.2 +- Bump `@polkadot/api` to 7.1.1 + + +## 0.42.2 Dec 10, 2021 + +Changes: + +- Fix bug introduced in 0.42.1 where account storage is not portable after the base port update + + +## 0.42.1 Dec 10, 2021 + +**Important** CHANGELOG entries are a rollup of details since last publish to the stores + +Contributed: + +- Allow for configuration of base ports (Thanks to https://github.com/AndreiEres) +- Adjust messaging for non-signRaw accounts (Thanks to https://github.com/BigBadAlien) +- Additional tests for Ethereum derivation (Thanks to https://github.com/joelamouche) + +Changes: + +- Adjust `chrome.*` location via polyfill on non-Chrome browsers +- Allow import of account via QR (where seed is provided) +- Expand error messaging for non-compatible Ledger chains +- Bump `@polkadot/util` to 8.1.2 +- Bump `@polkadot/api` to 6.11.1 + + +## 0.41.2 Nov 30, 2021 + +**Important** Not published to the stores, aligns with latest released packages. + +Changes: + +- Expand error messaging for non-compatible Ledger chains +- Bump `@polkadot/util` to 8.0.4 +- Bump `@polkadot/api` to 6.10.2 + + +## 0.41.1 Nov 8, 2021 + +**Important** CHANGELOG entries are a rollup of details since last publish to the stores + +Contributed: + +- Add search functionality (Thanks to https://github.com/Tbaut) +- Add Urdu translation (Thanks to https://github.com/itsonal) + +Changes: + +- Detect Ascii bytes (& display) when signing +- Correctly detect and create Ethereum-compatible chain accounts +- Ensure site authorization toggle is saved +- Optimize metadata conversion process +- Bump `@polkadot/util` to 7.8.2 +- Bump `@polkadot/api` to 6.7.1 + + +## 0.40.4 Oct 25, 2021 + +**Important** Not published to the stores, aligns with latest released packages. + +Changes: + +- Ensure site authorization toggle is saved +- Optimize metadata conversion process +- Bump `@polkadot/util` to 7.6.1 +- Bump `@polkadot/api` to 6.5.1 + + +## 0.40.3 Sep 18, 2021 + +**Important** Not published to the stores, aligns with latest released packages. + +Changes: + +- Expose `wrapBytes`, `unwrapBytes` directly from `@polkadot/util` +- Bump `@polkadot/util` to 7.4.1 +- Bump `@polkadot/api` to 6.0.1 + + +## 0.40.2 Sep 16, 2021 + +Changes: + +- Fix polish translation (valid JSON) + + +## 0.40.1 Sep 16, 2021 + +- **Important** The signatures generated now via the extension will be a wrapped data set, i.e. `signRaw` cannot be used directly to sign transactions, rather it is only meant to be used for actual messages +**Important** CHANGELOG entries are a rollup of details since last publish to the stores + +Contributed: + +- Support signing of raw data via Qr (Thanks to https://github.com/Tbaut, prior 0.38.4) +- Add Polish language support (Thanks to https://github.com/ccris02, prior 0.38.8) +- Add Thai language support (Thanks to https://github.com/Chakrarin) +- Display Ethereum formatted addressed for compatible chains (Thanks to https://github.com/joelamouche) +- Allow import of Metamask addresses for compatible chains (Thanks to https://github.com/joelamouche) +- Add configurable popup location (Thanks to https://github.com/shawntabrizi) + +Changes: + +- Raw signing interfaces will now always place a `...` wrapper around signed data (via `wrapBytes` in `extension-dapp`) +- Adjust raw signing outputs with data wrapper +- Adjust settings menu layouts +- Cater for v14 metadata formats +- Cater for `#` in phishing Urls as part of the checks +- Bump `@polkadot/api` & `@polkadot/util` to latest versions + + +## 0.39.3 Aug 16, 2021 + +**Important** Not published to the stores, aligns with latest released packages. + +Changes: + +- Bump `@polkadot/api` to `5.5.1` +- Bump `@polkadot/util` to `7.2.1` + + +## 0.39.2 Aug 2, 2021 + +**Important** Not published to the stores, aligns with latest released packages. + +Changes: + +- Bump `@polkadot/api` to `5.3.1` +- Bump `@polkadot/util` to `7.1.1` + + +## 0.39.1 Jul 11, 2021 + +**Important** Not published to the stores, aligns with latest released packages. + +Changes: + +- Allow building as a completely stand-alone browser bundle (experimental) +- Bump `@polkadot/api` to `5.0.1` +- Bump `@polkadot/util` to `7.0.1` + + +## 0.38.8 Jun 26, 2021 + +**Important** Not published to the stores, aligns with latest released packages. + +Contributed: + +- Add pl i18n (Thanks to https://github.com/ccris02) + +Changes: + +- Bump `@polkadot/api` to `4.17.1` +- Bump `@polkadot/util` to `6.11.1` + + +## 0.38.7 Jun 26, 2021 + +**Important** Not published to the stores, aligns with latest released packages. + +Changes: + +- Bump `@polkadot/api` to `4.16.1` +- Bump `@polkadot/util` to `6.10.1` + + +## 0.38.6 Jun 20, 2021 + +**Important** Not published to the stores, aligns with latest released packages. + +Changes: + +- Bump `@polkadot/api` to `4.15.1` +- Bump `@polkadot/util` to `6.9.1` + + +## 0.38.5 Jun 14, 2021 + +**Important** Not published to the stores, aligns with latest released packages. + +Changes: + +- Raw signing interface will not re-wrap Ethereum-type messages +- Bump `@polkadot/api` to `4.14.1` +- Bump `@polkadot/util` to `6.8.1` + + + +## 0.38.4 Jun 11, 2021 + +**Important** Not published to the stores, just made available to expose `{unwrap, wrap}Bytes` + +Contributed: + +- Support signing of raw data via Qr (Thanks to https://github.com/Tbaut) + +Changes: + +- Raw signing interfaces will now always place a `...` wrapper around signed data + + +## 0.38.3 May 31, 2021 + +Contributed: + +- Fix Chromium not displaying accounts due to height mismatch (Thanks to https://github.com/wirednkod) + + +## 0.38.2 May 30, 2021 + +**Important** Not published to the stores, just made available to ensure users can have access to a version that uses the latest `@polkadot/{api, util}` + +Changes: + +- Bump `@polkadot/api` to `4.12.1` +- Bump `@polkadot/util` to `6.6.1` + + +## 0.38.1 May 25, 2021 + +Contributed: + +- Support IPFS/IPNS uls (Thanks to https://github.com/carumusan) +- Batch export of all accounts (Thanks to https://github.com/BubbleBear) +- Turkish i18n (Thanks to https://github.com/zinderud) +- Support for custom signed extensions (Thanks to https://github.com/KarishmaBothara) +- Adjust background handler port mapping (Thanks to https://github.com/hlminh2000) +- Prevent 3rd party authorize abuse (Thanks to https://github.com/remon-nashid) +- Use file-saver for account export (Thanks to https://github.com/Tbaut) +- Language fixes (Thanks to https://github.com/n3wborn) + +Changes: + +- Support for Metadata v13 from Substrate +- Bump `@polkadot/api` & `@polkadot/util` to latest released versions +- Swap to use of ESM modules all in published packages + + +## 0.37.2 Feb 28, 2021 + +**Important** Not published to the stores, just made available to ensure users can have access to a version that uses the latest `@polkadot/{api, util}` + +Contributed: + +- Adjust tests to get rid of warnings (Thanks to https://github.com/Tbaut) + +Changes: + +- Bump `@polkadot/api` & `@polkadot/util` to latest released versions + + +## 0.37.1 Feb 10, 2021 + +Contributed: + +- Ensure accounts check against raw public keys (Thanks to https://github.com/yuzhiyou1990) +- Add support for Ledger devices (Thanks to https://github.com/Tbaut) +- Add network selectors on the creation of all accounts (Thanks to https://github.com/Tbaut) +- Add explicit derivation field on seed imports (Thanks to https://github.com/Tbaut) +- Adjust slider color for dark theme (Thanks to https://github.com/Tbaut) +- Expand and cleanup tests (Thanks to https://github.com/Tbaut) +- Allow custom chains to be selected as tie-to chains (Thanks to https://github.com/Tbaut) +- Various UI adjustments for consistency (Thanks to https://github.com/Tbaut) +- Update i18n fr (Thanks to https://github.com/Tbaut) + +Changes: + +- Support for latest JS APIs +- Adjust phishing detection to check newly opened tabs + + +## 0.36.1 Jan 5, 2021 + +Contributed: + +- Allow for the management of per-site approvals (Thanks to https://github.com/Tbaut) +- Add support for Ethereum account imports (Thanks to https://github.com/Tbaut) +- Split account derivation and from-seed creation flows (Thanks to https://github.com/Tbaut) +- Fix overlapping error labels (Thanks to https://github.com/Tbaut) +- Rework JSON restoration for consistency (Thanks to https://github.com/Tbaut) +- Leverage cache for phishing detection (Thanks to https://github.com/Tbaut) +- Allow ecdsa accounts to be injected (Thanks to https://github.com/Tbaut) +- Adjust display for overly long names (Thanks to https://github.com/Tbaut) +- Ensure that attached chain/prefix is always used on accounts (Thanks to https://github.com/Tbaut) +- Show account name (as entered) in creation screens (Thanks to https://github.com/Tbaut) +- show wrong password error on export screen (Thanks to https://github.com/Tbaut) +- Add new UI tests and fix skipped tests (Thanks to https://github.com/Tbaut) +- Additional fr translations (Thanks to https://github.com/Tbaut) + +Changes: + +- Swap to using Webpack 5 for reproducible builds +- Swap to using TypeScript type imports +- Hide parent/derivation-path when account is not derived + + +## 0.35.1 Nov 29, 2020 + +Contributed: + +- Add i18n French (Thanks to https://github.com/Tbaut) +- Add a caps-lock warning for passwords (Thanks to https://github.com/Tbaut) +- Unify warning/error messages between components (Thanks to https://github.com/Tbaut) +- Adjust notification window for cross-platform consistency (Thanks to https://github.com/Tbaut) +- Set account visibility directly from icon click (Thanks to https://github.com/Tbaut) +- Don't indicate name errors before any value is entered (Thanks to https://github.com/Tbaut) +- Swap icons to the Font Awesome (instead of built-in) (Thanks to https://github.com/Tbaut) +- Use `@polkadot/networks` for known ss58 formats/genesis (Thanks to https://github.com/Tbaut) +- Add phishing site detection and redirection (Thanks to https://github.com/Tbaut) +- Add indicator icon for external accounts (Thanks to https://github.com/Tbaut) +- Add error boundaries across all UI components (Thanks to https://github.com/Tbaut) +- Group accounts by network, sort by name & path (Thanks to https://github.com/Tbaut) +- Fix derive suggestions to update when switching root (Thanks to https://github.com/Tbaut) +- Adjust window opening logic to be generic (Thanks to https://github.com/Tbaut) +- Add i18n language selection dropdown (Thanks to https://github.com/Tbaut) +- Adjust password expiry to extend timeperiod (Thanks to https://github.com/Tbaut) +- Rework password caching for security & robustness (Thanks to https://github.com/Tbaut) +- Share password expiry length between back/front-ends (Thanks to https://github.com/Tbaut) +- Cleanup all global styles and usage (Thanks to https://github.com/Tbaut) + +Changes: + +- Adjust web3Enable for better on-load detection +- Support for all latest Substrate/Polkadot types + + +## 0.34.1 Sep 15, 2020 + +Contributed: + +- Add support for extension change password messaging (Thanks to https://github.com/remon-nashid) +- `web3Accounts` now allows the specification of the ss58Format (Thanks to https://github.com/Tbaut) + +Changes: + +- Support for latest Metadata v12 formats + + +## 0.33.4 Sep 9, 2020 + +Contributed: + +- Fix back button display on create account (Thanks to https://github.com/Tbaut) + +Changes: + +- Reproducible builds with Webpack optimization flags + + +## 0.33.2 Sep 7, 2020 + +Changes: + +- Fix zip output to correctly include all source files + + +## 0.33.1 Sep 7, 2020 + +Contributed: + +- Include Subsocial ss58 (Thanks to https://github.com/F3Joule) +- Add Crab network (Thanks to https://github.com/WoeOm) +- README updates (Thanks to https://github.com/Noc2) +- Runtime checks for web3Enable params (Thanks to https://github.com/Tbaut) + +Changes: + +- Add option to not ask password for 15 minutes (when signing transactions) +- Derived accounts uses the parent genesisHash by default (attaches to same chain) +- Make import from seed, QR & JSON options available on first-start +- Adjust popup width, allowing full display of e.g. addresses +- Always display network selection on all accounts +- Handling signing rejections (any order) transparently +- Small overall UI and use adjustments +- Latest upstream polkadot-js dependencies +- Prepare for i18n translations with initial i18next setup +- Rendering optimizations for Extrinsic displays + + +## 0.32.1 Jul 27, 2020 + +Contributed: + +- Add Kulupu to the chain lock dropdown (Thanks to https://github.com/carumusan) +- Minor README updates (Thanks to https://github.com/marceljay) + +Changes: + +- Allow enter on signing to screens to submit +- Update to v3 JSON file format (with kdf) +- Update Polkadot naming (dropping CC1) +- Add base known chain info to icon/ss58 display lookups +- Adjust IdentityIcon backgrounds between dark/light themes + + +## 0.31.1 Jun 24, 2020 + +Changes: + +- Indicate password error when account cannot be unlocked on signing +- Support for new Polkadot/Kusama/Substrate signing payloads + + +## 0.30.1 Jun 8, 2020 + +Contributed: + +- Add the ability to import JSON keystore files (Thanks to https://github.com/shawntabrizi) +- Updated to derivation documentation (Thanks to https://github.com/EthWorks) + +Changes: + +- Rework account creation with top-level menu +- Allow accounts to be hidden, i.e. not injected (per account setting) +- Adjust allowed mnemonic seed strengths, 12, 15, 18, 21 & 24 all allowed +- Allow accounts to be tied to a specific network genesis (along with display) +- Allow accounts to be made hidden, i.e. not injected into dapps +- Remove duplication with Default/Substrate prefixes in dropdown (equivalent, only generic displayed) +- Display child accounts when no parent has been found (orphans) +- Display derived suri alongside parent account names +- Remove all bundled metadata, update is available for dapps to keep current +- Sorting of injected accounts based on created timestamp + + +## 0.25.1 May 14, 2020 + +Contributed: + +- New account creation with default derivation (Thanks to https://github.com/EthWorks) + +Changes: + +- Adjust `web3Enable` promise to only resolve after the document has been loaded (is interactive) +- Update `signedExtensions` to cater for new chains +- Update metadata for latest Kusama + + +## 0.24.1 Apr 19, 2020 + +Contributed: + +- Allow for per root-account derivation & indicators (Thanks to https://github.com/EthWorks) +- Add consistent validation to all text inputs (Thanks to https://github.com/EthWorks) +- Make address copy interfaces easily accessible (Thanks to https://github.com/EthWorks) + +Changes: + +- Latest dependency updates, base types for all latest Polkadot/Substrate chains +- Rework base storage access & cross-browser interfaces for consistency +- UI consistency adjustments & code maintainability cleanups + + +## 0.23.1 Mar 26, 2020 + +Contributed: + +- Extract shared background code for re-use (Thanks to https://github.com/amaurymartiny) + +Changes: + +- Expose available genesisHash/specVersion to the dapps using the extension +- Allow prompts for metadata from dapps before decoding +- Add latest metadata for the Kusama network + + +## 0.22.1 Mar 03, 20202 + +Contributed: + +- Fix uncaught exception when tab closes without action (Thanks to https://github.com/amaurymartiny) +- Add preliminary support for provider injection, no UI config (Thanks to https://github.com/amaurymartiny) + +Changes: + +- Dependencies updated to latest versions + + +## 0.21.1 Feb 07, 20202 + +Changes: + +- Rebuild for re-publish +- Dependencies updated to latest versions + + +## 0.20.1 Jan 27, 2020 + +Contributed: + +- Redesign of all UI components and views (Thanks to https://github.com/EthWorks) + +Changes: + +- Account copy now respects the address formatting +- Updated to latest polkadot-js/api + + +## 0.14.1 Dec 10, 2019 + +Contributed: + +- Implement ability to sign raw messages (Thanks to https://github.com/c410-f3r) + +Changes: + +- Support for Kusama CC3 +- Allow the use of hex seeds as part of account creation + + +## 0.13.1 Oct 25, 2019 + +Contributed: + +- Account export functionality (Thanks to https://github.com/Anze1m) + +Changes: + +- Add a setting to switch off camera access +- Support for latest Polkadot/Substrate clients with v8 metadata & v4 transactions +- Remove support for non-operational Kusama CC1 network + + +## 0.12.1 Oct 02, 2019 + +Changes: + +- Support for Kusama CC2 +- Update to to latest stable dependencies + + +## 0.11.1 Sep 20, 2019 + +Changes: + +- Cleanup metadata handling, when outdated for a node, transparently handle parsing errors +- Added Edgeware chain & metadata information +- Display addresses correctly formatted based on the ss58 chain identifiers +- Display identity icons based on chain types for known chains +- Integrate latest @polkadot/util, @polkadot-js/ui & @polkadot/api dependencies +- Updated to Babel 7.6 (build and runtime improvements) + + +## 0.10.1 Sep 10, 2019 + +Changes: + +- Support for external accounts as presented by mobile signers, e.g. the Parity Signer +- Allow the extension UI to be opened in a new tab +- Adjust embedded chain metadata to only contain actual calls (for decoding) +- Minor code maintainability enhancements + + +## 0.9.1 Aug 31, 2019 + +Changes: + +- Fix an initialization error in extension-dapp + + +## 0.8.1 Aug 25, 2019 + +Changes: + +- Add basic support for seed derivation as part of the account import. Seeds can be followed by the derivation path, and derivation is applied on creation. +- Update the polkadot-js/api version to 0.90.1, the first non-beta version with full support for Kusama + + +## 0.7.1 Aug 19, 2019 + +Changes: + +- Updated the underlying polkadot-js/api version to support the most-recent signing payload extensions, as will be available on Kusama + + +## 0.6.1 Aug 03, 2019 + +Changes: + +- Support Extrinsics v3 from substrate 2.x, this signs an extrinsic with the genesisHash + + +## 0.5.1 Jul 25, 2019 + +Changes: + +- Always check for site permissions on messages, don't assume that messages originate from the libraries provided +- Change the injected Signer interface to support the upcoming Kusama transaction format + + +## 0.4.1 Jul 18, 2019 + +Changes: + +- Transactions are now signed with expiry information, so each transaction is mortal by default +- Unneeded scrollbars on Firefox does not appear anymore (when window is popped out) +- Cater for the setting of multiple network prefixes, e.g. Kusama +- Project icon has been updated + + +## 0.3.1 Jul 14, 2019 + +Changes: + +- Signing a transaction now displays the Mortal/Immortal status +- Don't request focus for popup window (this is not available on FF) +- `yarn build:zip` now builds a source zip as well (for store purposes) + + +## 0.2.1 Jul 12, 2019 + +Changes: + +- First release to Chrome and FireFox stores, basic functionality only diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..947952e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,45 @@ +# Contributing + +## What? + +Individuals making significant and valuable contributions are given commit-access to a project to contribute as they see fit. +A project is more like an open wiki than a standard guarded open source project. + +## Rules + +There are a few basic ground-rules for contributors (including the maintainer(s) of the project): + +1. **No `--force` pushes** or modifying the Git history in any way. If you need to rebase, ensure you do it in your own repo. +2. **Non-master branches**, prefixed with a short name moniker (e.g. `-`) must be used for ongoing work. +3. **All modifications** must be made in a **pull-request** to solicit feedback from other contributors. +4. A pull-request *must not be merged until CI* has finished successfully. + +#### Merging pull requests once CI is successful: +- A pull request with no large change to logic that is an urgent fix may be merged after a non-author contributor has reviewed it well. +- No PR should be merged until all reviews' comments are addressed. + +#### Reviewing pull requests: +When reviewing a pull request, the end-goal is to suggest useful changes to the author. Reviews should finish with approval unless there are issues that would result in: + +- Buggy behaviour. +- Undue maintenance burden. +- Breaking with house coding style. +- Pessimisation (i.e. reduction of speed as measured in the projects benchmarks). +- Feature reduction (i.e. it removes some aspect of functionality that a significant minority of users rely on). +- Uselessness (i.e. it does not strictly add a feature or fix a known issue). + +#### Reviews may not be used as an effective veto for a PR because: +- There exists a somewhat cleaner/better/faster way of accomplishing the same feature/fix. +- It does not fit well with some other contributors' longer-term vision for the project. + +## Releases + +Declaring formal releases remains the prerogative of the project maintainer(s). + +## Changes to this arrangement + +This is an experiment and feedback is welcome! This document may also be subject to pull-requests or changes by contributors where you believe you have something valuable to add or change. + +## Heritage + +These contributing guidelines are modified from the "OPEN Open Source Project" guidelines for the Level project: [https://github.com/Level/community/blob/master/CONTRIBUTING.md](https://github.com/Level/community/blob/master/CONTRIBUTING.md) diff --git a/CONTRIBUTORS b/CONTRIBUTORS new file mode 100644 index 0000000..5fa3e2e --- /dev/null +++ b/CONTRIBUTORS @@ -0,0 +1,47 @@ + 634 Jaco 2024 (#1302) + 120 Tarik Gul Add AHM warning page (#1601) + 93 Thibaut Sardan Ability to Reject an authentication request instead of ignoring it (#1453) + 24 Arjun Porwal 0.62.6 (#1604) + 7 Ivan Rukhavets Mention derivation in FAQ (#334) + 6 Francis O'Brien feat: add cancel authorization request functionality and improve UI components (#1491) + 5 Alberto Nicolas Penayo fix(bug): extension stuck in `... loading ...` screen after `service_worker` got terminated (#1424) + 5 rajk93 fix: signing extrinsics on ledger device (#1579) + 4 Amaury Martiny PostMessageProvider with on('connected' | 'disconnected') (#279) + 3 Antoine Estienne Add eth test for extension signature (#909) + 3 Ryan Lee chore: update module resolution to bundler (#1387) + 2 Axel Chalon Add typings to messages (#130) + 2 Bartłomiej Rutkowski Allow account creation by derivation from existing one (#296) + 2 carumusan Support ipfs and ipns urls (#667) + 2 ccris02 Adding Polish Language Option to the Extension (#761) + 2 Dusan Morhac Update XCM Analyser to v1.3.1 (#1419) + 2 hamidra add extension filtered account subscriptions (#1063) + 2 joe petrowski add plasm (#462) + 2 Kami Rename getAllMetatdata to getAllMetadata (#1168) + 2 Remon Nashid Prevent unauthorized apps from abusing pub(authorize.tab) request (#686) + 2 Shawn Tabrizi Add configurable notifications (#767) + 2 Sonal Banerjii Add Bengali translation (#1049) + 2 WoeOm add darwinia network (#493) + 1 Aleksandr Ishchenko [#897] signer.signRaw signing doesn't work with Ledger Nano X (#905) + 1 Andrei Eres Add extension prefix (#891) + 1 Caio Implement signRaw (#196) + 1 Chakrarin Sarnt Add Thai language (#788) + 1 CustomBlink Add authorized URL removal (#1029) + 1 David Hawig Update README.md (#403) + 1 f00 fix(authorize): long urls pushes the cta down (#1005) + 1 Forrest Add Web3AccountsOptions to web3FromAddress function (#617) + 1 Gérard Dethier chore: upgrade web3. (#1340) + 1 Giovanny Gongora change textarea for div to show the mnemonic seed (#1147) + 1 KarishmaBothara Added code to support custom signed extension/user extension (#670) + 1 Marcel Jackisch Minor changes to readme (#376) + 1 Michael Healy Small typo (#932) + 1 Minh Ha ✨ allows background handler to handle custom extention port name (#685) + 1 murat onur tr language support added (#681) + 1 Nikos Kontakis Fix issue that was breaking extension ui (height collapsed and non-visible); Fixes #720 (#731) + 1 Pedro Filho adding correct nvm version (#1075) + 1 roiLeo fix(RemoveAuth): icon cursor & size (#1082) + 1 Stéphane P README Fix typo (#700) + 1 Tiến Nguyễn Khắc fix: extension does not get injected on page load (#1486) + 1 Tony Chen fix & add more zh transalation (#1160) + 1 Tore19 Add Stafi network (#454) + 1 Vlad Proshchavaiev Update extension with Subsocial SS58 included (#416) + 1 雪霁 Batch export (#666) \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0d381b2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..543cb48 --- /dev/null +++ b/README.md @@ -0,0 +1,163 @@ +# polkadot{.js} extension + +A very simple scaffolding browser extension that injects a [@polkadot/api](https://github.com/pezkuwi/api) Signer into a page, along with any associated accounts, allowing for use by any dapp. This is an extensible POC implementation of a Pezkuwi/Substrate browser signer. + +As it stands, it does one thing: it _only_ manages accounts and allows the signing of transactions with those accounts. It does not inject providers for use by dapps at this early point, nor does it perform wallet functions where it constructs and submits txs to the network. + +## Installation + +- On Chrome, install via [Chrome web store](https://chrome.google.com/webstore/detail/polkadot%7Bjs%7D-extension/mopnmbcafieddcagagdcbnhejhlodfdd) +- On Firefox, install via [Firefox add-ons](https://addons.mozilla.org/en-US/firefox/addon/pezkuwi-extension/) + +![interface screenshots](docs/extension-overview.png) + +## Documentation and examples +Find out more about how to use the extension as a Dapp developper, cookbook, as well as answers to most frequent questions in the [Pezkuwi-js extension documentation](https://js.pezkuwichain.app/docs/extension/) + +## Firefox installation from source instructions. + +1. Uncompress `master-ff-src.zip` +2. Run `corepack enable` [More information](https://github.com/nodejs/corepack?tab=readme-ov-file#corepack-enable--name) +2. Install dependencies via `yarn install` +3. Build all packages via `yarn build` + - The `/packages/extension/build` directory will contain the exact code used in the add-on, and should exactly match the uncompressed `master-ff-build`. + +NOTE: If you would like to regenerate the compressed `master-ff-build.zip`, and `master-ff-src.zip` files run: `yarn build:zip:ff` + +## Development version + +Steps to build the extension and view your changes in a browser: + +1. Chrome: + 1. Build via `yarn build:chrome` + - NOTE: You may need to enable corepack by running `corepack enable` + 2. Install the extension + - go to `chrome://extensions/` + - ensure you have the Development flag set + - "Load unpacked" and point to `packages/extension/build` + - if developing, after making changes - refresh the extension +2. Firefox + 1. Build via `yarn build:ff` + - NOTE: You may need to enable corepack by running `corepack enable` + 2. Install the extension + - go to `about:debugging#addons` + - check "Enable add-on debugging" + - click on "Load Temporary Add-on" and point to `packages/extension/build/manifest.json` + - if developing, after making changes - reload the extension +3. When visiting `https://js.pezkuwichain.app/apps/` it will inject the extension + +Once added, you can create an account (via a generated seed) or import via an existing seed. The [apps UI](https://github.com/pezkuwi/apps/), when loaded, will show these accounts as ` (extension)` + +## Development + +The repo is split into a number of packages - + +- [extension](packages/extension/) - All the injection and background processing logic (the main entry) +- [extension-ui](packages/extension-ui/) - The UI components for the extension, to build up the popup +- [extension-dapp](packages/extension-dapp/) - A convenience wrapper to work with the injected objects, simplifying data extraction for any dapp that wishes to integrate the extension (or any extension that supports the interface) +- [extension-inject](packages/extension-inject/) - A convenience wrapper that allows extension developers to inject their extension for use by any dapp + +It also contains a [`manifest_chrome.json`](packages/extension/manifest_chrome.json) file which contains the manifest configuration for Chrome and another [`manifest_firefox.json`](packages/extension/manifest_firefox.json) with the configuration for Firefox, for compatibility reasons, and a dummy `manifest.json` file that's only used by the build. + +## Dapp developers + +The actual in-depth technical breakdown is given in the next section for any dapp developer wishing to work with the raw objects injected into the window. However, convenience wrappers are provided that allows for any dapp to use this extension (or any other extension that conforms to the interface) without having to manage any additional info. + +The documentation for Dapp development is available [in the pezkuwi doc](https://js.pezkuwichain.app/docs/extension). + +This approach is used to support multiple external signers in for instance [apps](https://github.com/pezkuwi/apps/). You can read more about the convenience wrapper [@polkadot/extension-dapp](packages/extension-dapp/) along with usage samples. + +## API interface + +The extension injection interfaces are generic, i.e. it is designed to allow any extension developer to easily inject extensions (that conforms to a specific interface) and at the same time, it allows for any dapp developer to easily enable the interfaces from multiple extensions at the same time. It is not an all-or-nothing approach, but rather it is an ecosystem where the user can choose which extensions fit their style best. + +From a dapp developer perspective, the only work needed is to include the [@polkadot/extension-dapp](packages/extension-dapp/) package and call the appropriate enabling function to retrieve all the extensions and their associated interfaces. + +From an extension developer perspective, the only work required is to enable the extension via the razor-thin [@polkadot/extension-inject](packages/extension-inject/) wrapper. Any dapp using the above interfaces will have access to the extension via this interface. + +When there is more than one extension, each will populate an entry via the injection interface and each will be made available to the dapp. The `Injected` interface, as returned via `enable`, contains the following information for any compliant extension - + +```js +interface Injected { + // the interface for Accounts, as detailed below + readonly accounts: Accounts; + // the standard Signer interface for the API, as detailed below + readonly signer: Signer; + // not injected as of yet, subscribable provider for pezkuwi API injection, + // this can be passed to the API itself upon construction in the dapp + // readonly provider?: Provider +} + +interface Account = { + // ss-58 encoded address + readonly address: string; + // the genesisHash for this account (empty if applicable to all) + readonly genesisHash?: string; + // (optional) name for display + readonly name?: string; +}; + +// exposes accounts +interface Accounts { + // retrieves the list of accounts for right now + get: () => Promise; + // (optional) subscribe to all accounts, updating as they change + subscribe?: (cb: (accounts: Account[]) => any) => () => void +} + +// a signer that communicates with the extension via sendMessage +interface Signer extends SignerInterface { + // no specific signer extensions, exposes the `sign` interface for use by + // the pezkuwi API, confirming the Signer interface for this API +} +``` + +## Injection information + +The information contained in this section may change and evolve. It is therefore recommended that all access is done via the [@polkadot/extension-dapp](packages/extension-dapp/) (for dapps) and [extension-inject](packages/extension-inject/) (for extensions) packages, which removes the need to work with the lower-level targets. + +The extension injects `injectedWeb3` into the global `window` object, exposing the following: (This is meant to be generic across extensions, allowing any dapp to utilize multiple signers, and pull accounts from multiples, as they are available.) + +```js +window.injectedWeb3 = { + // this is the name for this extension, there could be multiples injected, + // each with their own keys, here `pezkuwi` is for this extension + 'pezkuwi': { + // semver for the package + version: '0.1.0', + + // this is called to enable the injection, and returns an injected + // object containing the accounts, signer and provider interfaces + // (or it will reject if not authorized) + enable (originName: string): Promise + } +} +``` + +## Mnemonics, Passwords, and Imports/Exports + +### Using the mnemonic and password from the extension + +When you create a keypair via the extension, it supplies a 12-word mnemonic seed and asks you to create a password. This password only encrypts the private key on disk so that the password is required to spend funds in `pezkuwi/apps` or to import the account from backup. The password does not protect the mnemonic phrase. That is, if an attacker were to acquire the mnemonic phrase, they would be able to use it to spend funds without the password. + +### Importing mnemonics from other key generation utilities + +Some key-generation tools, e.g. [Subkey](https://www.substrate.io/kb/integrate/subkey), support hard and soft key derivation as well as passwords that encrypt the mnemonic phrase such that the mnemonic phrase itself is insufficient to spend funds. + +The extension supports these advanced features. When you import an account from a seed, you can add these derivation paths or password to the end of the mnemonic in the following format: + +``` +////// +``` + +That is, hard-derivation paths are prefixed with `//`, soft paths with `/`, and the password with `///`. + +The extension will still ask you to enter a password for this account. As before, this password only encrypts the private key on disk. It is not required to be the same password as the one that encrypts the mnemonic phrase. + +Accounts can also be derived from existing accounts – `Derive New Account` option in account's dropdown menu should be selected. After providing the password of the parent account, along with name and password of the derived account, enter derivation path in the following format: + +``` +/// +``` + +The path will be added to the mnemonic phrase of the parent account. diff --git a/docs/.nojekyll b/docs/.nojekyll new file mode 100644 index 0000000..e69de29 diff --git a/docs/assets/background_top.svg b/docs/assets/background_top.svg new file mode 100644 index 0000000..59d981c --- /dev/null +++ b/docs/assets/background_top.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/assets/chrome.svg b/docs/assets/chrome.svg new file mode 100644 index 0000000..5ecc640 --- /dev/null +++ b/docs/assets/chrome.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/assets/firefox.svg b/docs/assets/firefox.svg new file mode 100644 index 0000000..3d123b5 --- /dev/null +++ b/docs/assets/firefox.svg @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/assets/header_logo.svg b/docs/assets/header_logo.svg new file mode 100644 index 0000000..e46a447 --- /dev/null +++ b/docs/assets/header_logo.svg @@ -0,0 +1,22 @@ + +
+ + + + + + + + + + + + + + + + + + + +
diff --git a/docs/assets/hero.svg b/docs/assets/hero.svg new file mode 100644 index 0000000..6cfd433 --- /dev/null +++ b/docs/assets/hero.svg @@ -0,0 +1,154 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/assets/logo.svg b/docs/assets/logo.svg new file mode 100644 index 0000000..9484667 --- /dev/null +++ b/docs/assets/logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/docs/extension-overview.png b/docs/extension-overview.png new file mode 100644 index 0000000..265237d Binary files /dev/null and b/docs/extension-overview.png differ diff --git a/docs/favicon.ico b/docs/favicon.ico new file mode 100644 index 0000000..f4cdb3e Binary files /dev/null and b/docs/favicon.ico differ diff --git a/docs/fonts/Inter/Inter-Italic-VariableFont_opsz,wght.ttf b/docs/fonts/Inter/Inter-Italic-VariableFont_opsz,wght.ttf new file mode 100644 index 0000000..43ed4f5 Binary files /dev/null and b/docs/fonts/Inter/Inter-Italic-VariableFont_opsz,wght.ttf differ diff --git a/docs/fonts/Inter/Inter-VariableFont_opsz,wght.ttf b/docs/fonts/Inter/Inter-VariableFont_opsz,wght.ttf new file mode 100644 index 0000000..e31b51e Binary files /dev/null and b/docs/fonts/Inter/Inter-VariableFont_opsz,wght.ttf differ diff --git a/docs/fonts/Inter/OFL.txt b/docs/fonts/Inter/OFL.txt new file mode 100644 index 0000000..d05ec4b --- /dev/null +++ b/docs/fonts/Inter/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/docs/fonts/Inter/README.txt b/docs/fonts/Inter/README.txt new file mode 100644 index 0000000..f7a47e8 --- /dev/null +++ b/docs/fonts/Inter/README.txt @@ -0,0 +1,118 @@ +Inter Variable Font +=================== + +This download contains Inter as both variable fonts and static fonts. + +Inter is a variable font with these axes: + opsz + wght + +This means all the styles are contained in these files: + Inter-VariableFont_opsz,wght.ttf + Inter-Italic-VariableFont_opsz,wght.ttf + +If your app fully supports variable fonts, you can now pick intermediate styles +that aren’t available as static fonts. Not all apps support variable fonts, and +in those cases you can use the static font files for Inter: + static/Inter_18pt-Thin.ttf + static/Inter_18pt-ExtraLight.ttf + static/Inter_18pt-Light.ttf + static/Inter_18pt-Regular.ttf + static/Inter_18pt-Medium.ttf + static/Inter_18pt-SemiBold.ttf + static/Inter_18pt-Bold.ttf + static/Inter_18pt-ExtraBold.ttf + static/Inter_18pt-Black.ttf + static/Inter_24pt-Thin.ttf + static/Inter_24pt-ExtraLight.ttf + static/Inter_24pt-Light.ttf + static/Inter_24pt-Regular.ttf + static/Inter_24pt-Medium.ttf + static/Inter_24pt-SemiBold.ttf + static/Inter_24pt-Bold.ttf + static/Inter_24pt-ExtraBold.ttf + static/Inter_24pt-Black.ttf + static/Inter_28pt-Thin.ttf + static/Inter_28pt-ExtraLight.ttf + static/Inter_28pt-Light.ttf + static/Inter_28pt-Regular.ttf + static/Inter_28pt-Medium.ttf + static/Inter_28pt-SemiBold.ttf + static/Inter_28pt-Bold.ttf + static/Inter_28pt-ExtraBold.ttf + static/Inter_28pt-Black.ttf + static/Inter_18pt-ThinItalic.ttf + static/Inter_18pt-ExtraLightItalic.ttf + static/Inter_18pt-LightItalic.ttf + static/Inter_18pt-Italic.ttf + static/Inter_18pt-MediumItalic.ttf + static/Inter_18pt-SemiBoldItalic.ttf + static/Inter_18pt-BoldItalic.ttf + static/Inter_18pt-ExtraBoldItalic.ttf + static/Inter_18pt-BlackItalic.ttf + static/Inter_24pt-ThinItalic.ttf + static/Inter_24pt-ExtraLightItalic.ttf + static/Inter_24pt-LightItalic.ttf + static/Inter_24pt-Italic.ttf + static/Inter_24pt-MediumItalic.ttf + static/Inter_24pt-SemiBoldItalic.ttf + static/Inter_24pt-BoldItalic.ttf + static/Inter_24pt-ExtraBoldItalic.ttf + static/Inter_24pt-BlackItalic.ttf + static/Inter_28pt-ThinItalic.ttf + static/Inter_28pt-ExtraLightItalic.ttf + static/Inter_28pt-LightItalic.ttf + static/Inter_28pt-Italic.ttf + static/Inter_28pt-MediumItalic.ttf + static/Inter_28pt-SemiBoldItalic.ttf + static/Inter_28pt-BoldItalic.ttf + static/Inter_28pt-ExtraBoldItalic.ttf + static/Inter_28pt-BlackItalic.ttf + +Get started +----------- + +1. Install the font files you want to use + +2. Use your app's font picker to view the font family and all the +available styles + +Learn more about variable fonts +------------------------------- + + https://developers.google.com/web/fundamentals/design-and-ux/typography/variable-fonts + https://variablefonts.typenetwork.com + https://medium.com/variable-fonts + +In desktop apps + + https://theblog.adobe.com/can-variable-fonts-illustrator-cc + https://helpx.adobe.com/nz/photoshop/using/fonts.html#variable_fonts + +Online + + https://developers.google.com/fonts/docs/getting_started + https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Fonts/Variable_Fonts_Guide + https://developer.microsoft.com/en-us/microsoft-edge/testdrive/demos/variable-fonts + +Installing fonts + + MacOS: https://support.apple.com/en-us/HT201749 + Linux: https://www.google.com/search?q=how+to+install+a+font+on+gnu%2Blinux + Windows: https://support.microsoft.com/en-us/help/314960/how-to-install-or-remove-a-font-in-windows + +Android Apps + + https://developers.google.com/fonts/docs/android + https://developer.android.com/guide/topics/ui/look-and-feel/downloadable-fonts + +License +------- +Please read the full license text (OFL.txt) to understand the permissions, +restrictions and requirements for usage, redistribution, and modification. + +You can use them in your products & projects – print or digital, +commercial or otherwise. + +This isn't legal advice, please consider consulting a lawyer and see the full +license for all details. diff --git a/docs/fonts/Inter/static/Inter_18pt-Black.ttf b/docs/fonts/Inter/static/Inter_18pt-Black.ttf new file mode 100644 index 0000000..89673de Binary files /dev/null and b/docs/fonts/Inter/static/Inter_18pt-Black.ttf differ diff --git a/docs/fonts/Inter/static/Inter_18pt-BlackItalic.ttf b/docs/fonts/Inter/static/Inter_18pt-BlackItalic.ttf new file mode 100644 index 0000000..b33602f Binary files /dev/null and b/docs/fonts/Inter/static/Inter_18pt-BlackItalic.ttf differ diff --git a/docs/fonts/Inter/static/Inter_18pt-Bold.ttf b/docs/fonts/Inter/static/Inter_18pt-Bold.ttf new file mode 100644 index 0000000..cd13f60 Binary files /dev/null and b/docs/fonts/Inter/static/Inter_18pt-Bold.ttf differ diff --git a/docs/fonts/Inter/static/Inter_18pt-BoldItalic.ttf b/docs/fonts/Inter/static/Inter_18pt-BoldItalic.ttf new file mode 100644 index 0000000..0d19c1a Binary files /dev/null and b/docs/fonts/Inter/static/Inter_18pt-BoldItalic.ttf differ diff --git a/docs/fonts/Inter/static/Inter_18pt-ExtraBold.ttf b/docs/fonts/Inter/static/Inter_18pt-ExtraBold.ttf new file mode 100644 index 0000000..e71c601 Binary files /dev/null and b/docs/fonts/Inter/static/Inter_18pt-ExtraBold.ttf differ diff --git a/docs/fonts/Inter/static/Inter_18pt-ExtraBoldItalic.ttf b/docs/fonts/Inter/static/Inter_18pt-ExtraBoldItalic.ttf new file mode 100644 index 0000000..df45062 Binary files /dev/null and b/docs/fonts/Inter/static/Inter_18pt-ExtraBoldItalic.ttf differ diff --git a/docs/fonts/Inter/static/Inter_18pt-ExtraLight.ttf b/docs/fonts/Inter/static/Inter_18pt-ExtraLight.ttf new file mode 100644 index 0000000..f9c6cfc Binary files /dev/null and b/docs/fonts/Inter/static/Inter_18pt-ExtraLight.ttf differ diff --git a/docs/fonts/Inter/static/Inter_18pt-ExtraLightItalic.ttf b/docs/fonts/Inter/static/Inter_18pt-ExtraLightItalic.ttf new file mode 100644 index 0000000..275f305 Binary files /dev/null and b/docs/fonts/Inter/static/Inter_18pt-ExtraLightItalic.ttf differ diff --git a/docs/fonts/Inter/static/Inter_18pt-Italic.ttf b/docs/fonts/Inter/static/Inter_18pt-Italic.ttf new file mode 100644 index 0000000..14d3595 Binary files /dev/null and b/docs/fonts/Inter/static/Inter_18pt-Italic.ttf differ diff --git a/docs/fonts/Inter/static/Inter_18pt-Light.ttf b/docs/fonts/Inter/static/Inter_18pt-Light.ttf new file mode 100644 index 0000000..acae361 Binary files /dev/null and b/docs/fonts/Inter/static/Inter_18pt-Light.ttf differ diff --git a/docs/fonts/Inter/static/Inter_18pt-LightItalic.ttf b/docs/fonts/Inter/static/Inter_18pt-LightItalic.ttf new file mode 100644 index 0000000..f69e18b Binary files /dev/null and b/docs/fonts/Inter/static/Inter_18pt-LightItalic.ttf differ diff --git a/docs/fonts/Inter/static/Inter_18pt-Medium.ttf b/docs/fonts/Inter/static/Inter_18pt-Medium.ttf new file mode 100644 index 0000000..71d9017 Binary files /dev/null and b/docs/fonts/Inter/static/Inter_18pt-Medium.ttf differ diff --git a/docs/fonts/Inter/static/Inter_18pt-MediumItalic.ttf b/docs/fonts/Inter/static/Inter_18pt-MediumItalic.ttf new file mode 100644 index 0000000..5c8c8b1 Binary files /dev/null and b/docs/fonts/Inter/static/Inter_18pt-MediumItalic.ttf differ diff --git a/docs/fonts/Inter/static/Inter_18pt-Regular.ttf b/docs/fonts/Inter/static/Inter_18pt-Regular.ttf new file mode 100644 index 0000000..ce097c8 Binary files /dev/null and b/docs/fonts/Inter/static/Inter_18pt-Regular.ttf differ diff --git a/docs/fonts/Inter/static/Inter_18pt-SemiBold.ttf b/docs/fonts/Inter/static/Inter_18pt-SemiBold.ttf new file mode 100644 index 0000000..053185e Binary files /dev/null and b/docs/fonts/Inter/static/Inter_18pt-SemiBold.ttf differ diff --git a/docs/fonts/Inter/static/Inter_18pt-SemiBoldItalic.ttf b/docs/fonts/Inter/static/Inter_18pt-SemiBoldItalic.ttf new file mode 100644 index 0000000..d9c9896 Binary files /dev/null and b/docs/fonts/Inter/static/Inter_18pt-SemiBoldItalic.ttf differ diff --git a/docs/fonts/Inter/static/Inter_18pt-Thin.ttf b/docs/fonts/Inter/static/Inter_18pt-Thin.ttf new file mode 100644 index 0000000..e68ec47 Binary files /dev/null and b/docs/fonts/Inter/static/Inter_18pt-Thin.ttf differ diff --git a/docs/fonts/Inter/static/Inter_18pt-ThinItalic.ttf b/docs/fonts/Inter/static/Inter_18pt-ThinItalic.ttf new file mode 100644 index 0000000..134e837 Binary files /dev/null and b/docs/fonts/Inter/static/Inter_18pt-ThinItalic.ttf differ diff --git a/docs/fonts/Inter/static/Inter_24pt-Black.ttf b/docs/fonts/Inter/static/Inter_24pt-Black.ttf new file mode 100644 index 0000000..dbb1b3b Binary files /dev/null and b/docs/fonts/Inter/static/Inter_24pt-Black.ttf differ diff --git a/docs/fonts/Inter/static/Inter_24pt-BlackItalic.ttf b/docs/fonts/Inter/static/Inter_24pt-BlackItalic.ttf new file mode 100644 index 0000000..b89d61c Binary files /dev/null and b/docs/fonts/Inter/static/Inter_24pt-BlackItalic.ttf differ diff --git a/docs/fonts/Inter/static/Inter_24pt-Bold.ttf b/docs/fonts/Inter/static/Inter_24pt-Bold.ttf new file mode 100644 index 0000000..46b3583 Binary files /dev/null and b/docs/fonts/Inter/static/Inter_24pt-Bold.ttf differ diff --git a/docs/fonts/Inter/static/Inter_24pt-BoldItalic.ttf b/docs/fonts/Inter/static/Inter_24pt-BoldItalic.ttf new file mode 100644 index 0000000..d1c0f53 Binary files /dev/null and b/docs/fonts/Inter/static/Inter_24pt-BoldItalic.ttf differ diff --git a/docs/fonts/Inter/static/Inter_24pt-ExtraBold.ttf b/docs/fonts/Inter/static/Inter_24pt-ExtraBold.ttf new file mode 100644 index 0000000..b775c08 Binary files /dev/null and b/docs/fonts/Inter/static/Inter_24pt-ExtraBold.ttf differ diff --git a/docs/fonts/Inter/static/Inter_24pt-ExtraBoldItalic.ttf b/docs/fonts/Inter/static/Inter_24pt-ExtraBoldItalic.ttf new file mode 100644 index 0000000..3461a92 Binary files /dev/null and b/docs/fonts/Inter/static/Inter_24pt-ExtraBoldItalic.ttf differ diff --git a/docs/fonts/Inter/static/Inter_24pt-ExtraLight.ttf b/docs/fonts/Inter/static/Inter_24pt-ExtraLight.ttf new file mode 100644 index 0000000..2ec6ca3 Binary files /dev/null and b/docs/fonts/Inter/static/Inter_24pt-ExtraLight.ttf differ diff --git a/docs/fonts/Inter/static/Inter_24pt-ExtraLightItalic.ttf b/docs/fonts/Inter/static/Inter_24pt-ExtraLightItalic.ttf new file mode 100644 index 0000000..c634a5d Binary files /dev/null and b/docs/fonts/Inter/static/Inter_24pt-ExtraLightItalic.ttf differ diff --git a/docs/fonts/Inter/static/Inter_24pt-Italic.ttf b/docs/fonts/Inter/static/Inter_24pt-Italic.ttf new file mode 100644 index 0000000..1048b07 Binary files /dev/null and b/docs/fonts/Inter/static/Inter_24pt-Italic.ttf differ diff --git a/docs/fonts/Inter/static/Inter_24pt-Light.ttf b/docs/fonts/Inter/static/Inter_24pt-Light.ttf new file mode 100644 index 0000000..1a2a6f2 Binary files /dev/null and b/docs/fonts/Inter/static/Inter_24pt-Light.ttf differ diff --git a/docs/fonts/Inter/static/Inter_24pt-LightItalic.ttf b/docs/fonts/Inter/static/Inter_24pt-LightItalic.ttf new file mode 100644 index 0000000..ded5a75 Binary files /dev/null and b/docs/fonts/Inter/static/Inter_24pt-LightItalic.ttf differ diff --git a/docs/fonts/Inter/static/Inter_24pt-Medium.ttf b/docs/fonts/Inter/static/Inter_24pt-Medium.ttf new file mode 100644 index 0000000..5c88739 Binary files /dev/null and b/docs/fonts/Inter/static/Inter_24pt-Medium.ttf differ diff --git a/docs/fonts/Inter/static/Inter_24pt-MediumItalic.ttf b/docs/fonts/Inter/static/Inter_24pt-MediumItalic.ttf new file mode 100644 index 0000000..be091b1 Binary files /dev/null and b/docs/fonts/Inter/static/Inter_24pt-MediumItalic.ttf differ diff --git a/docs/fonts/Inter/static/Inter_24pt-Regular.ttf b/docs/fonts/Inter/static/Inter_24pt-Regular.ttf new file mode 100644 index 0000000..6b088a7 Binary files /dev/null and b/docs/fonts/Inter/static/Inter_24pt-Regular.ttf differ diff --git a/docs/fonts/Inter/static/Inter_24pt-SemiBold.ttf b/docs/fonts/Inter/static/Inter_24pt-SemiBold.ttf new file mode 100644 index 0000000..ceb8576 Binary files /dev/null and b/docs/fonts/Inter/static/Inter_24pt-SemiBold.ttf differ diff --git a/docs/fonts/Inter/static/Inter_24pt-SemiBoldItalic.ttf b/docs/fonts/Inter/static/Inter_24pt-SemiBoldItalic.ttf new file mode 100644 index 0000000..6921df2 Binary files /dev/null and b/docs/fonts/Inter/static/Inter_24pt-SemiBoldItalic.ttf differ diff --git a/docs/fonts/Inter/static/Inter_24pt-Thin.ttf b/docs/fonts/Inter/static/Inter_24pt-Thin.ttf new file mode 100644 index 0000000..3505b35 Binary files /dev/null and b/docs/fonts/Inter/static/Inter_24pt-Thin.ttf differ diff --git a/docs/fonts/Inter/static/Inter_24pt-ThinItalic.ttf b/docs/fonts/Inter/static/Inter_24pt-ThinItalic.ttf new file mode 100644 index 0000000..a3e6feb Binary files /dev/null and b/docs/fonts/Inter/static/Inter_24pt-ThinItalic.ttf differ diff --git a/docs/fonts/Inter/static/Inter_28pt-Black.ttf b/docs/fonts/Inter/static/Inter_28pt-Black.ttf new file mode 100644 index 0000000..66a252f Binary files /dev/null and b/docs/fonts/Inter/static/Inter_28pt-Black.ttf differ diff --git a/docs/fonts/Inter/static/Inter_28pt-BlackItalic.ttf b/docs/fonts/Inter/static/Inter_28pt-BlackItalic.ttf new file mode 100644 index 0000000..3c8fdf9 Binary files /dev/null and b/docs/fonts/Inter/static/Inter_28pt-BlackItalic.ttf differ diff --git a/docs/fonts/Inter/static/Inter_28pt-Bold.ttf b/docs/fonts/Inter/static/Inter_28pt-Bold.ttf new file mode 100644 index 0000000..d17828b Binary files /dev/null and b/docs/fonts/Inter/static/Inter_28pt-Bold.ttf differ diff --git a/docs/fonts/Inter/static/Inter_28pt-BoldItalic.ttf b/docs/fonts/Inter/static/Inter_28pt-BoldItalic.ttf new file mode 100644 index 0000000..6fce50a Binary files /dev/null and b/docs/fonts/Inter/static/Inter_28pt-BoldItalic.ttf differ diff --git a/docs/fonts/Inter/static/Inter_28pt-ExtraBold.ttf b/docs/fonts/Inter/static/Inter_28pt-ExtraBold.ttf new file mode 100644 index 0000000..6d87cae Binary files /dev/null and b/docs/fonts/Inter/static/Inter_28pt-ExtraBold.ttf differ diff --git a/docs/fonts/Inter/static/Inter_28pt-ExtraBoldItalic.ttf b/docs/fonts/Inter/static/Inter_28pt-ExtraBoldItalic.ttf new file mode 100644 index 0000000..1a56735 Binary files /dev/null and b/docs/fonts/Inter/static/Inter_28pt-ExtraBoldItalic.ttf differ diff --git a/docs/fonts/Inter/static/Inter_28pt-ExtraLight.ttf b/docs/fonts/Inter/static/Inter_28pt-ExtraLight.ttf new file mode 100644 index 0000000..d42b3f5 Binary files /dev/null and b/docs/fonts/Inter/static/Inter_28pt-ExtraLight.ttf differ diff --git a/docs/fonts/Inter/static/Inter_28pt-ExtraLightItalic.ttf b/docs/fonts/Inter/static/Inter_28pt-ExtraLightItalic.ttf new file mode 100644 index 0000000..90e2f20 Binary files /dev/null and b/docs/fonts/Inter/static/Inter_28pt-ExtraLightItalic.ttf differ diff --git a/docs/fonts/Inter/static/Inter_28pt-Italic.ttf b/docs/fonts/Inter/static/Inter_28pt-Italic.ttf new file mode 100644 index 0000000..c2a143a Binary files /dev/null and b/docs/fonts/Inter/static/Inter_28pt-Italic.ttf differ diff --git a/docs/fonts/Inter/static/Inter_28pt-Light.ttf b/docs/fonts/Inter/static/Inter_28pt-Light.ttf new file mode 100644 index 0000000..5eeff3a Binary files /dev/null and b/docs/fonts/Inter/static/Inter_28pt-Light.ttf differ diff --git a/docs/fonts/Inter/static/Inter_28pt-LightItalic.ttf b/docs/fonts/Inter/static/Inter_28pt-LightItalic.ttf new file mode 100644 index 0000000..6b90b76 Binary files /dev/null and b/docs/fonts/Inter/static/Inter_28pt-LightItalic.ttf differ diff --git a/docs/fonts/Inter/static/Inter_28pt-Medium.ttf b/docs/fonts/Inter/static/Inter_28pt-Medium.ttf new file mode 100644 index 0000000..00120fe Binary files /dev/null and b/docs/fonts/Inter/static/Inter_28pt-Medium.ttf differ diff --git a/docs/fonts/Inter/static/Inter_28pt-MediumItalic.ttf b/docs/fonts/Inter/static/Inter_28pt-MediumItalic.ttf new file mode 100644 index 0000000..7481e7b Binary files /dev/null and b/docs/fonts/Inter/static/Inter_28pt-MediumItalic.ttf differ diff --git a/docs/fonts/Inter/static/Inter_28pt-Regular.ttf b/docs/fonts/Inter/static/Inter_28pt-Regular.ttf new file mode 100644 index 0000000..855b6f4 Binary files /dev/null and b/docs/fonts/Inter/static/Inter_28pt-Regular.ttf differ diff --git a/docs/fonts/Inter/static/Inter_28pt-SemiBold.ttf b/docs/fonts/Inter/static/Inter_28pt-SemiBold.ttf new file mode 100644 index 0000000..8b84efc Binary files /dev/null and b/docs/fonts/Inter/static/Inter_28pt-SemiBold.ttf differ diff --git a/docs/fonts/Inter/static/Inter_28pt-SemiBoldItalic.ttf b/docs/fonts/Inter/static/Inter_28pt-SemiBoldItalic.ttf new file mode 100644 index 0000000..2e22c5a Binary files /dev/null and b/docs/fonts/Inter/static/Inter_28pt-SemiBoldItalic.ttf differ diff --git a/docs/fonts/Inter/static/Inter_28pt-Thin.ttf b/docs/fonts/Inter/static/Inter_28pt-Thin.ttf new file mode 100644 index 0000000..94e6108 Binary files /dev/null and b/docs/fonts/Inter/static/Inter_28pt-Thin.ttf differ diff --git a/docs/fonts/Inter/static/Inter_28pt-ThinItalic.ttf b/docs/fonts/Inter/static/Inter_28pt-ThinItalic.ttf new file mode 100644 index 0000000..d3d44cd Binary files /dev/null and b/docs/fonts/Inter/static/Inter_28pt-ThinItalic.ttf differ diff --git a/docs/fonts/Manrope/Manrope-VariableFont_wght.ttf b/docs/fonts/Manrope/Manrope-VariableFont_wght.ttf new file mode 100644 index 0000000..f39ca39 Binary files /dev/null and b/docs/fonts/Manrope/Manrope-VariableFont_wght.ttf differ diff --git a/docs/fonts/Manrope/OFL.txt b/docs/fonts/Manrope/OFL.txt new file mode 100644 index 0000000..b8eafd9 --- /dev/null +++ b/docs/fonts/Manrope/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2018 The Manrope Project Authors (https://github.com/sharanda/manrope) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/docs/fonts/Manrope/README.txt b/docs/fonts/Manrope/README.txt new file mode 100644 index 0000000..160c305 --- /dev/null +++ b/docs/fonts/Manrope/README.txt @@ -0,0 +1,69 @@ +Manrope Variable Font +===================== + +This download contains Manrope as both a variable font and static fonts. + +Manrope is a variable font with this axis: + wght + +This means all the styles are contained in a single file: + Manrope-VariableFont_wght.ttf + +If your app fully supports variable fonts, you can now pick intermediate styles +that aren’t available as static fonts. Not all apps support variable fonts, and +in those cases you can use the static font files for Manrope: + static/Manrope-ExtraLight.ttf + static/Manrope-Light.ttf + static/Manrope-Regular.ttf + static/Manrope-Medium.ttf + static/Manrope-SemiBold.ttf + static/Manrope-Bold.ttf + static/Manrope-ExtraBold.ttf + +Get started +----------- + +1. Install the font files you want to use + +2. Use your app's font picker to view the font family and all the +available styles + +Learn more about variable fonts +------------------------------- + + https://developers.google.com/web/fundamentals/design-and-ux/typography/variable-fonts + https://variablefonts.typenetwork.com + https://medium.com/variable-fonts + +In desktop apps + + https://theblog.adobe.com/can-variable-fonts-illustrator-cc + https://helpx.adobe.com/nz/photoshop/using/fonts.html#variable_fonts + +Online + + https://developers.google.com/fonts/docs/getting_started + https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Fonts/Variable_Fonts_Guide + https://developer.microsoft.com/en-us/microsoft-edge/testdrive/demos/variable-fonts + +Installing fonts + + MacOS: https://support.apple.com/en-us/HT201749 + Linux: https://www.google.com/search?q=how+to+install+a+font+on+gnu%2Blinux + Windows: https://support.microsoft.com/en-us/help/314960/how-to-install-or-remove-a-font-in-windows + +Android Apps + + https://developers.google.com/fonts/docs/android + https://developer.android.com/guide/topics/ui/look-and-feel/downloadable-fonts + +License +------- +Please read the full license text (OFL.txt) to understand the permissions, +restrictions and requirements for usage, redistribution, and modification. + +You can use them in your products & projects – print or digital, +commercial or otherwise. + +This isn't legal advice, please consider consulting a lawyer and see the full +license for all details. diff --git a/docs/fonts/Manrope/static/Manrope-Bold.ttf b/docs/fonts/Manrope/static/Manrope-Bold.ttf new file mode 100644 index 0000000..98c1c3d Binary files /dev/null and b/docs/fonts/Manrope/static/Manrope-Bold.ttf differ diff --git a/docs/fonts/Manrope/static/Manrope-ExtraBold.ttf b/docs/fonts/Manrope/static/Manrope-ExtraBold.ttf new file mode 100644 index 0000000..369d719 Binary files /dev/null and b/docs/fonts/Manrope/static/Manrope-ExtraBold.ttf differ diff --git a/docs/fonts/Manrope/static/Manrope-ExtraLight.ttf b/docs/fonts/Manrope/static/Manrope-ExtraLight.ttf new file mode 100644 index 0000000..8915d96 Binary files /dev/null and b/docs/fonts/Manrope/static/Manrope-ExtraLight.ttf differ diff --git a/docs/fonts/Manrope/static/Manrope-Light.ttf b/docs/fonts/Manrope/static/Manrope-Light.ttf new file mode 100644 index 0000000..4942924 Binary files /dev/null and b/docs/fonts/Manrope/static/Manrope-Light.ttf differ diff --git a/docs/fonts/Manrope/static/Manrope-Medium.ttf b/docs/fonts/Manrope/static/Manrope-Medium.ttf new file mode 100644 index 0000000..5eda9ec Binary files /dev/null and b/docs/fonts/Manrope/static/Manrope-Medium.ttf differ diff --git a/docs/fonts/Manrope/static/Manrope-Regular.ttf b/docs/fonts/Manrope/static/Manrope-Regular.ttf new file mode 100644 index 0000000..1a07233 Binary files /dev/null and b/docs/fonts/Manrope/static/Manrope-Regular.ttf differ diff --git a/docs/fonts/Manrope/static/Manrope-SemiBold.ttf b/docs/fonts/Manrope/static/Manrope-SemiBold.ttf new file mode 100644 index 0000000..b6e9c20 Binary files /dev/null and b/docs/fonts/Manrope/static/Manrope-SemiBold.ttf differ diff --git a/docs/fonts/Unbounded/OFL.txt b/docs/fonts/Unbounded/OFL.txt new file mode 100644 index 0000000..18503c0 --- /dev/null +++ b/docs/fonts/Unbounded/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Unbounded Project Authors (https://github.com/googlefonts/unbounded) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/docs/fonts/Unbounded/README.txt b/docs/fonts/Unbounded/README.txt new file mode 100644 index 0000000..3212b38 --- /dev/null +++ b/docs/fonts/Unbounded/README.txt @@ -0,0 +1,70 @@ +Unbounded Variable Font +======================= + +This download contains Unbounded as both a variable font and static fonts. + +Unbounded is a variable font with this axis: + wght + +This means all the styles are contained in a single file: + Unbounded-VariableFont_wght.ttf + +If your app fully supports variable fonts, you can now pick intermediate styles +that aren’t available as static fonts. Not all apps support variable fonts, and +in those cases you can use the static font files for Unbounded: + static/Unbounded-ExtraLight.ttf + static/Unbounded-Light.ttf + static/Unbounded-Regular.ttf + static/Unbounded-Medium.ttf + static/Unbounded-SemiBold.ttf + static/Unbounded-Bold.ttf + static/Unbounded-ExtraBold.ttf + static/Unbounded-Black.ttf + +Get started +----------- + +1. Install the font files you want to use + +2. Use your app's font picker to view the font family and all the +available styles + +Learn more about variable fonts +------------------------------- + + https://developers.google.com/web/fundamentals/design-and-ux/typography/variable-fonts + https://variablefonts.typenetwork.com + https://medium.com/variable-fonts + +In desktop apps + + https://theblog.adobe.com/can-variable-fonts-illustrator-cc + https://helpx.adobe.com/nz/photoshop/using/fonts.html#variable_fonts + +Online + + https://developers.google.com/fonts/docs/getting_started + https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Fonts/Variable_Fonts_Guide + https://developer.microsoft.com/en-us/microsoft-edge/testdrive/demos/variable-fonts + +Installing fonts + + MacOS: https://support.apple.com/en-us/HT201749 + Linux: https://www.google.com/search?q=how+to+install+a+font+on+gnu%2Blinux + Windows: https://support.microsoft.com/en-us/help/314960/how-to-install-or-remove-a-font-in-windows + +Android Apps + + https://developers.google.com/fonts/docs/android + https://developer.android.com/guide/topics/ui/look-and-feel/downloadable-fonts + +License +------- +Please read the full license text (OFL.txt) to understand the permissions, +restrictions and requirements for usage, redistribution, and modification. + +You can use them in your products & projects – print or digital, +commercial or otherwise. + +This isn't legal advice, please consider consulting a lawyer and see the full +license for all details. diff --git a/docs/fonts/Unbounded/Unbounded-VariableFont_wght.ttf b/docs/fonts/Unbounded/Unbounded-VariableFont_wght.ttf new file mode 100644 index 0000000..33e8e9c Binary files /dev/null and b/docs/fonts/Unbounded/Unbounded-VariableFont_wght.ttf differ diff --git a/docs/fonts/Unbounded/static/Unbounded-Black.ttf b/docs/fonts/Unbounded/static/Unbounded-Black.ttf new file mode 100644 index 0000000..8e28e35 Binary files /dev/null and b/docs/fonts/Unbounded/static/Unbounded-Black.ttf differ diff --git a/docs/fonts/Unbounded/static/Unbounded-Bold.ttf b/docs/fonts/Unbounded/static/Unbounded-Bold.ttf new file mode 100644 index 0000000..598face Binary files /dev/null and b/docs/fonts/Unbounded/static/Unbounded-Bold.ttf differ diff --git a/docs/fonts/Unbounded/static/Unbounded-ExtraBold.ttf b/docs/fonts/Unbounded/static/Unbounded-ExtraBold.ttf new file mode 100644 index 0000000..0dadeb5 Binary files /dev/null and b/docs/fonts/Unbounded/static/Unbounded-ExtraBold.ttf differ diff --git a/docs/fonts/Unbounded/static/Unbounded-ExtraLight.ttf b/docs/fonts/Unbounded/static/Unbounded-ExtraLight.ttf new file mode 100644 index 0000000..f233e93 Binary files /dev/null and b/docs/fonts/Unbounded/static/Unbounded-ExtraLight.ttf differ diff --git a/docs/fonts/Unbounded/static/Unbounded-Light.ttf b/docs/fonts/Unbounded/static/Unbounded-Light.ttf new file mode 100644 index 0000000..82e98c6 Binary files /dev/null and b/docs/fonts/Unbounded/static/Unbounded-Light.ttf differ diff --git a/docs/fonts/Unbounded/static/Unbounded-Medium.ttf b/docs/fonts/Unbounded/static/Unbounded-Medium.ttf new file mode 100644 index 0000000..aa09345 Binary files /dev/null and b/docs/fonts/Unbounded/static/Unbounded-Medium.ttf differ diff --git a/docs/fonts/Unbounded/static/Unbounded-Regular.ttf b/docs/fonts/Unbounded/static/Unbounded-Regular.ttf new file mode 100644 index 0000000..5f60354 Binary files /dev/null and b/docs/fonts/Unbounded/static/Unbounded-Regular.ttf differ diff --git a/docs/fonts/Unbounded/static/Unbounded-SemiBold.ttf b/docs/fonts/Unbounded/static/Unbounded-SemiBold.ttf new file mode 100644 index 0000000..683397a Binary files /dev/null and b/docs/fonts/Unbounded/static/Unbounded-SemiBold.ttf differ diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..ec1de4c --- /dev/null +++ b/docs/index.html @@ -0,0 +1,99 @@ + + + + Polkadot-js extension, manage accounts for substrate based chains + + + + + + + + + + + + + +
+ + Polkadot.JS extension has been re-branded to the Polkadot Developer Signer +
+ + +
+ +
+ Logo +
+ + +
+
+
+
+
This extensions is made for developers
+

Polkadot Developer Signer

+

+ The Polkadot Developer Signer allows only for account creation + and allows the signing of transactions. +

+
+ + +
+ +
+

More Info

+

+ This extension is open source and the code is available on + + GitHub. +

+

+ For developers wanting to use the accounts from the extension in a + Dapp, head to the developer + + documentation. +

+
+
+
+ Polkadot Developer Signer +
+
+
+ + + + diff --git a/docs/logo.jpg b/docs/logo.jpg new file mode 100644 index 0000000..5b28f46 Binary files /dev/null and b/docs/logo.jpg differ diff --git a/docs/style.css b/docs/style.css new file mode 100644 index 0000000..b009d6b --- /dev/null +++ b/docs/style.css @@ -0,0 +1,274 @@ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +@font-face { + font-family: "Unbounded"; + src: url("fonts/Unbounded/Unbounded-VariableFont_wght.ttf"); +} + +@font-face { + font-family: "Inter"; + src: url("fonts/Inter/Inter-VariableFont_opsz\,wght.ttf"); +} + +@font-face { + font-family: "Manrope"; + src: url("fonts/Manrope/Manrope-VariableFont_wght.ttf"); +} + +:root { + --primary: #000000e5; + --secondary: #e0195d; + --background: #e5e7eb; + --info: #fed204; + + font-family: "Unbounded"; + font-weight: 300; +} + +body { + background: url("./assets/background_top.svg") no-repeat top/cover; + background-size: 100% auto; + background-color: var(--background); + color: var(--primary); +} + +.notification__banner { + background: #181919; + color: #ffffff; + font-family: Inter; + font-weight: 300; + font-size: 16px; + line-height: 150%; + letter-spacing: 0px; + padding: 8px; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; +} + +.notification__banner img { + width: 18px; + height: 18px; +} + +#main { + max-width: 65vw; + margin-inline: auto; +} + +.header__wrapper { + padding-block: 3.5rem; +} + +.hero__wrapper { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1.5rem; +} + +.hero__left { + display: flex; + flex-direction: column; +} + +.badge { + background: var(--info); + border-radius: 23.46px; + padding: 8px 12px; + font-family: Inter; + font-weight: 600; + font-size: 14px; + line-height: 100%; + letter-spacing: 0%; + text-align: center; + vertical-align: middle; + color: #1a1a1a; + max-width: fit-content; +} + +.hero__info { + display: flex; + flex-direction: column; + gap: 6rem; +} + +.hero__infoDetails { + display: flex; + flex-direction: column; + gap: 16px; +} + +.hero__infoDetails h2 { + max-width: 457px; + height: 180px; + font-family: Unbounded; + font-weight: 400; + font-size: 50px; + line-height: 120%; + letter-spacing: -2.25px; + color: var(--primary); +} + +.hero__infoDetails p { + max-width: 509px; + height: 54px; + font-family: Manrope; + font-weight: 400; + font-size: 18px; + line-height: 150%; + letter-spacing: 0px; + color: #000000b2; +} + +.hero__cta { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; +} + +.hero__cta a { + text-decoration: none; + width: 273px; + height: 56px; + border-radius: 6px; + padding: 16px 24px; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + background: #140523; + font-family: Unbounded; + font-weight: 400; + font-size: 12px; + line-height: 18px; + letter-spacing: 0%; + text-align: center; + vertical-align: middle; + text-transform: uppercase; + color: #ffffff; +} + +.hero__cta a:hover { + transition: background 0.2s ease-in; + background: var(--secondary); +} + +.hero__moreInfo { + margin-top: 80px; + max-width: 543px; + border-radius: 16px; + display: flex; + flex-direction: column; + background: #ffffff4a; + border: 1px solid gainsboro; + gap: 8px; + padding: 16px; + backdrop-filter: blur(120px); +} + +.hero__moreInfo h4 { + font-family: Unbounded; + font-weight: 500; + font-size: 14px; + line-height: 150%; + letter-spacing: 3%; + text-transform: uppercase; + color: #140523; +} + +.hero__moreInfo p { + font-family: Manrope; + font-weight: 400; + font-size: 16px; + line-height: 150%; + letter-spacing: 0px; + color: #000000b2; +} + +.hero__moreInfo p a { + font-family: Manrope; + font-weight: 400; + font-size: 16px; + line-height: 150%; + letter-spacing: 0px; + text-decoration: underline; + text-decoration-style: solid; + text-underline-offset: 2px; + text-decoration-thickness: 4%; + color: #000000b2; +} + +.hero__moreInfo p a:hover { + transition: background 0.2s ease-in; + color: var(--secondary); +} + +.hero__right { + display: flex; + justify-content: end; +} + +@media screen and (max-width: 1650px) { + #main { + max-width: 80vw; + } +} + +@media screen and (max-width: 1400px) { + #main { + max-width: 85vw; + width: 90%; + } + + .hero__moreInfo { + width: 100%; + } + + .hero__right img { + width: 80%; + } +} + +@media screen and (max-width: 1200px) { + .hero__right { + align-self: center; + } + + .hero__right img { + width: 85%; + height: 100%; + } +} + +@media screen and (max-width: 1040px) { + .hero__wrapper { + grid-template-columns: repeat(1, 1fr); + gap: 4rem; + } + + .hero__moreInfo { + max-width: 100%; + } + + .hero__right img { + width: 100%; + height: 100%; + } +} + +@media screen and (max-width: 600px) { + .hero__wrapper { + gap: 2.5rem; + } + + .hero__moreInfo { + margin-top: 40px; + max-width: fit-content; + } +} diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..07c3d27 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,19 @@ +// Copyright 2017-2025 @polkadot/extension authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import baseConfig from '@polkadot/dev/config/eslint'; + +export default [ + ...baseConfig, + { + rules: { + 'import/extensions': 'off' + } + }, + { + files: ['**/*.spec.ts', '**/*.spec.tsx'], + rules: { + 'deprecation/deprecation': 'off' + } + } +]; diff --git a/i18next-scanner.config.cjs b/i18next-scanner.config.cjs new file mode 100644 index 0000000..569601e --- /dev/null +++ b/i18next-scanner.config.cjs @@ -0,0 +1,57 @@ +// Copyright 2019-2025 @polkadot/extension authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +const fs = require('fs'); +const path = require('path'); +const typescript = require('typescript'); + +function transform (file, enc, done) { + const { ext } = path.parse(file.path); + + if (ext === '.tsx') { + const content = fs.readFileSync(file.path, enc); + + const { outputText } = typescript.transpileModule(content, { + compilerOptions: { + target: 'es2018' + }, + fileName: path.basename(file.path) + }); + + this.parser.parseFuncFromString(outputText); + } + + done(); +} + +module.exports = { + input: [ + 'packages/extension-ui/src/**/*.{ts,tsx}', + // Use ! to filter out files or directories + '!packages/*/src/**/*.spec.{ts,tsx}', + '!packages/*/src/i18n/**', + '!**/node_modules/**' + ], + options: { + debug: false, // true to print config + defaultLng: 'en', + func: { + extensions: ['.tsx', '.ts'], + list: ['t', 'i18next.t', 'i18n.t'] + }, + keySeparator: false, // key separator + lngs: ['en'], + nsSeparator: false, // namespace separator + resource: { + jsonIndent: 2, + lineEnding: '\n', + loadPath: 'packages/extension/public/locales/{{lng}}/{{ns}}.json', + savePath: 'packages/extension/public/locales/{{lng}}/{{ns}}.json' + }, + trans: { + component: 'Trans' + } + }, + output: './', + transform +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..87fd6b2 --- /dev/null +++ b/package.json @@ -0,0 +1,65 @@ +{ + "author": "Jaco Greeff ", + "bugs": "https://github.com/pezkuwichain/pezkuwi-extension/issues", + "engines": { + "node": ">=18.14" + }, + "homepage": "https://github.com/pezkuwichain/pezkuwi-extension#readme", + "license": "Apache-2.0", + "packageManager": "yarn@4.9.2", + "private": true, + "repository": { + "type": "git", + "url": "https://github.com/pezkuwichain/pezkuwi-extension.git" + }, + "sideEffects": false, + "type": "module", + "version": "0.62.6", + "versions": { + "git": "0.62.6", + "npm": "0.62.6" + }, + "workspaces": [ + "packages/*" + ], + "scripts": { + "build": "yarn build:chrome && yarn build:ff", + "build:before": "yarn build:i18n", + "build:chrome": "cp ./packages/extension/manifest_chrome.json ./packages/extension/manifest.json && polkadot-dev-build-ts && yarn build:zip:chrome && yarn build:rollup", + "build:ff": "cp ./packages/extension/manifest_firefox.json ./packages/extension/manifest.json && polkadot-dev-build-ts && yarn build:zip:ff && yarn build:rollup", + "build:i18n": "i18next-scanner --config i18next-scanner.config.cjs", + "build:release": "polkadot-ci-ghact-build", + "build:rollup": "polkadot-exec-rollup --config", + "build:zip": "yarn build:zip:chrome && yarn build:zip:ff", + "build:zip:chrome": "cp ./packages/extension/manifest_chrome.json ./packages/extension/manifest.json && yarn build:zip:dst:chrome && yarn build:zip:src:chrome", + "build:zip:dst:chrome": "rm -rf ./master-chrome-build.zip && cd packages/extension/build && zip -r -FS ../../../master-chrome-build.zip .", + "build:zip:dst:ff": "rm -rf ./master-ff-build.zip && cd packages/extension/build && zip -r -FS ../../../master-ff-build.zip .", + "build:zip:ff": "cp ./packages/extension/manifest_firefox.json ./packages/extension/manifest.json && yarn build:zip:dst:ff && yarn build:zip:src:ff", + "build:zip:src:chrome": "rm -rf ./master-chrome-src.zip && zip -r -x '*build/*' -x '*node_modules*' -FS ./master-chrome-src.zip packages .editorconfig eslint.config.js rollup.config.js CHANGELOG.md CONTRIBUTING.md i18next-scanner.config.cjs LICENSE package.json README.md tsconfig.json yarn.lock .yarnrc.yml tsconfig.base.json tsconfig.build.json tsconfig.eslint.json tsconfig.webpack.json", + "build:zip:src:ff": "rm -rf ./master-ff-src.zip && zip -r -x '*build/*' -x '*node_modules*' -FS ./master-ff-src.zip packages .editorconfig eslint.config.js rollup.config.js CHANGELOG.md CONTRIBUTING.md i18next-scanner.config.cjs LICENSE package.json README.md tsconfig.json yarn.lock .yarnrc.yml tsconfig.base.json tsconfig.build.json tsconfig.eslint.json tsconfig.webpack.json", + "clean": "polkadot-dev-clean-build", + "diff": "rm -rf ff-diff && sh ./scripts/diff.sh", + "lint": "polkadot-dev-run-lint", + "postinstall": "polkadot-dev-yarn-only", + "test": "EXTENSION_PREFIX='test' polkadot-dev-run-test --loader ./packages/extension-mocks/src/loader-empty.js --env browser ^:.spec.tsx", + "test:one": "EXTENSION_PREFIX='test' polkadot-dev-run-test --env browser" + }, + "devDependencies": { + "@pezkuwi/dev": "^0.84.3", + "@types/node": "^20.10.5", + "i18next-scanner": "^4.4.0", + "sinon-chrome": "^3.0.1" + }, + "resolutions": { + "@pezkuwi/api": "^16.5.3", + "@pezkuwi/keyring": "^14.0.5", + "@pezkuwi/networks": "^14.0.5", + "@pezkuwi/rpc-provider": "^16.5.3", + "@pezkuwi/types": "^16.5.3", + "@pezkuwi/util": "^14.0.5", + "@pezkuwi/util-crypto": "^14.0.5", + "@pezkuwi/x-fetch": "^13.5.9", + "safe-buffer": "^5.2.1", + "typescript": "^5.5.4" + } +} diff --git a/packages/extension-base/README.md b/packages/extension-base/README.md new file mode 100644 index 0000000..9b2d2fa --- /dev/null +++ b/packages/extension-base/README.md @@ -0,0 +1,12 @@ +# @polkadot/extension-base + +Functions, classes and other utilities used in `@polkadot/extension`. These include: +- background script handlers, +- message passing, +- scripts injected inside pages. + +They are primarily meant to be used in `@polkadot/extension`, and can be broken without any notice to cater for `@polkadot/extension`'s needs. + +They are exported here if you wish to use part of them in the development of your +own extension. Don't forget to add `process.env.EXTENSION_PREFIX` to separate +ports and stores from the current extension's ones. diff --git a/packages/extension-base/package.json b/packages/extension-base/package.json new file mode 100644 index 0000000..3575c7a --- /dev/null +++ b/packages/extension-base/package.json @@ -0,0 +1,45 @@ +{ + "author": "Jaco Greeff ", + "bugs": "https://github.com/pezkuwichain/pezkuwi-extension/issues", + "description": "Functions, classes and other utilities used in @pezkuwi/extension", + "engines": { + "node": ">=18" + }, + "homepage": "https://github.com/pezkuwichain/pezkuwi-extension/tree/master/packages/extension-base#readme", + "license": "Apache-2.0", + "name": "@pezkuwi/extension-base", + "repository": { + "directory": "packages/extension-base", + "type": "git", + "url": "https://github.com/pezkuwichain/pezkuwi-extension.git" + }, + "sideEffects": [ + "./packageDetect.js", + "./packageDetect.cjs" + ], + "type": "module", + "version": "0.62.6", + "main": "index.js", + "dependencies": { + "@pezkuwi/api": "^16.5.3", + "@pezkuwi/extension-chains": "0.62.6", + "@pezkuwi/extension-dapp": "0.62.6", + "@pezkuwi/extension-inject": "0.62.6", + "@pezkuwi/keyring": "^14.0.5", + "@pezkuwi/networks": "^14.0.5", + "@pezkuwi/phishing": "^0.25.23", + "@pezkuwi/rpc-provider": "^16.5.3", + "@pezkuwi/types": "^16.5.3", + "@pezkuwi/ui-keyring": "^3.16.4", + "@pezkuwi/ui-settings": "^3.16.4", + "@pezkuwi/util": "^14.0.5", + "@pezkuwi/util-crypto": "^14.0.5", + "eventemitter3": "^5.0.1", + "rxjs": "^7.8.1", + "tslib": "^2.8.1" + }, + "devDependencies": { + "@pezkuwi/dev-test": "^0.84.3", + "@pezkuwi/extension-mocks": "0.62.6" + } +} diff --git a/packages/extension-base/src/background/RequestBytesSign.ts b/packages/extension-base/src/background/RequestBytesSign.ts new file mode 100644 index 0000000..984b9bc --- /dev/null +++ b/packages/extension-base/src/background/RequestBytesSign.ts @@ -0,0 +1,28 @@ +// Copyright 2019-2025 @pezkuwi/extension-base authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { KeyringPair } from '@pezkuwi/keyring/types'; +import type { TypeRegistry } from '@pezkuwi/types'; +import type { SignerPayloadRaw } from '@pezkuwi/types/types'; +import type { HexString } from '@pezkuwi/util/types'; +import type { RequestSign } from './types.js'; + +import { u8aToHex, u8aWrapBytes } from '@pezkuwi/util'; + +export default class RequestBytesSign implements RequestSign { + public readonly payload: SignerPayloadRaw; + + constructor (payload: SignerPayloadRaw) { + this.payload = payload; + } + + sign (_registry: TypeRegistry, pair: KeyringPair): { signature: HexString } { + return { + signature: u8aToHex( + pair.sign( + u8aWrapBytes(this.payload.data) + ) + ) + }; + } +} diff --git a/packages/extension-base/src/background/RequestExtrinsicSign.ts b/packages/extension-base/src/background/RequestExtrinsicSign.ts new file mode 100644 index 0000000..f855098 --- /dev/null +++ b/packages/extension-base/src/background/RequestExtrinsicSign.ts @@ -0,0 +1,22 @@ +// Copyright 2019-2025 @pezkuwi/extension-base authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { KeyringPair } from '@pezkuwi/keyring/types'; +import type { TypeRegistry } from '@pezkuwi/types'; +import type { SignerPayloadJSON } from '@pezkuwi/types/types'; +import type { HexString } from '@pezkuwi/util/types'; +import type { RequestSign } from './types.js'; + +export default class RequestExtrinsicSign implements RequestSign { + public readonly payload: SignerPayloadJSON; + + constructor (payload: SignerPayloadJSON) { + this.payload = payload; + } + + sign (registry: TypeRegistry, pair: KeyringPair): { signature: HexString } { + return registry + .createType('ExtrinsicPayload', this.payload, { version: this.payload.version }) + .sign(pair); + } +} diff --git a/packages/extension-base/src/background/handlers/Extension.spec.ts b/packages/extension-base/src/background/handlers/Extension.spec.ts new file mode 100644 index 0000000..b816293 --- /dev/null +++ b/packages/extension-base/src/background/handlers/Extension.spec.ts @@ -0,0 +1,478 @@ +// Copyright 2019-2025 @pezkuwi/extension-base authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/* global chrome */ + +import '@pezkuwi/extension-mocks/chrome'; + +import type * as _ from '@pezkuwi/dev-test/globals.d.ts'; +import type { ResponseSigning } from '@pezkuwi/extension-base/background/types'; +import type { MetadataDef } from '@pezkuwi/extension-inject/types'; +import type { KeyringPair } from '@pezkuwi/keyring/types'; +import type { ExtDef } from '@pezkuwi/types/extrinsic/signedExtensions/types'; +import type { SignerPayloadJSON } from '@pezkuwi/types/types'; +import type { KeypairType } from '@pezkuwi/util-crypto/types'; + +import { TypeRegistry } from '@pezkuwi/types'; +import keyring from '@pezkuwi/ui-keyring'; +import { cryptoWaitReady } from '@pezkuwi/util-crypto'; + +import { AccountsStore } from '../../stores/index.js'; +import Extension from './Extension.js'; +import State from './State.js'; +import Tabs from './Tabs.js'; + +describe('Extension', () => { + let extension: Extension; + let state: State; + let tabs: Tabs; + const suri = 'seed sock milk update focus rotate barely fade car face mechanic mercy'; + const password = 'passw0rd'; + + async function createExtension (): Promise { + try { + await cryptoWaitReady(); + + keyring.loadAll({ store: new AccountsStore() }); + + state = new State({}, 0); + await state.init(); + tabs = new Tabs(state); + + return new Extension(state); + } catch (e) { + console.error(e); + + throw e; + } + } + + const createAccount = async (type?: KeypairType): Promise => { + await extension.handle('id', 'pri(accounts.create.suri)', type && type === 'ethereum' + ? { + name: 'parent', + password, + suri, + type + } + : { + name: 'parent', + password, + suri + }, {} as chrome.runtime.Port); + const { address } = await extension.handle('id', 'pri(seed.validate)', type && type === 'ethereum' + ? { + suri, + type + } + : { + suri + }, {} as chrome.runtime.Port); + + return address; + }; + + beforeAll(async () => { + extension = await createExtension(); + }); + + it('exports account from keyring', async () => { + const { pair: { address } } = keyring.addUri(suri, password); + const result = await extension.handle('id', 'pri(accounts.export)', { + address, + password + }, {} as chrome.runtime.Port); + + expect(result.exportedJson.address).toBe(address); + expect(result.exportedJson.encoded).toBeDefined(); + }); + + describe('account derivation', () => { + let address: string; + + beforeEach(async () => { + address = await createAccount(); + }); + + it('pri(derivation.validate) passes for valid suri', async () => { + const result = await extension.handle('id', 'pri(derivation.validate)', { + parentAddress: address, + parentPassword: password, + suri: '//path' + }, {} as chrome.runtime.Port); + + expect(result).toEqual({ + address: '5FP3TT3EruYBNh8YM8yoxsreMx7uZv1J1zNX7fFhoC5enwmN', + suri: '//path' + }); + }); + + it('pri(derivation.validate) throws for invalid suri', async () => { + await expect(extension.handle('id', 'pri(derivation.validate)', { + parentAddress: address, + parentPassword: password, + suri: 'invalid-path' + }, {} as chrome.runtime.Port)).rejects.toThrow(/is not a valid derivation path/); + }); + + it('pri(derivation.validate) throws for invalid password', async () => { + await expect(extension.handle('id', 'pri(derivation.validate)', { + parentAddress: address, + parentPassword: 'invalid-password', + suri: '//path' + }, {} as chrome.runtime.Port)).rejects.toThrow(/invalid password/); + }); + + it('pri(derivation.create) adds a derived account', async () => { + await extension.handle('id', 'pri(derivation.create)', { + name: 'child', + parentAddress: address, + parentPassword: password, + password, + suri: '//path' + }, {} as chrome.runtime.Port); + expect(keyring.getAccounts()).toHaveLength(2); + }); + + it('pri(derivation.create) saves parent address in meta', async () => { + await extension.handle('id', 'pri(derivation.create)', { + name: 'child', + parentAddress: address, + parentPassword: password, + password, + suri: '//path' + }, {} as chrome.runtime.Port); + expect(keyring.getAccount('5FP3TT3EruYBNh8YM8yoxsreMx7uZv1J1zNX7fFhoC5enwmN')?.meta.parentAddress).toEqual(address); + }); + }); + + describe('account management', () => { + let address: string; + + beforeEach(async () => { + address = await createAccount(); + }); + + it('pri(accounts.changePassword) changes account password', async () => { + const newPass = 'pa55word'; + const wrongPass = 'ZZzzZZzz'; + + await expect(extension.handle('id', 'pri(accounts.changePassword)', { + address, + newPass, + oldPass: wrongPass + }, {} as chrome.runtime.Port)).rejects.toThrow(/oldPass is invalid/); + + const res = await extension.handle('id', 'pri(accounts.changePassword)', { + address, + newPass, + oldPass: password + }, {} as chrome.runtime.Port); + + expect(res).toEqual(true); + + const pair = keyring.getPair(address); + + expect(pair.decodePkcs8(newPass)).toEqual(undefined); + + expect(() => { + pair.decodePkcs8(password); + }).toThrow(/Unable to decode using the supplied passphrase/); + }); + }); + + describe('custom user extension', () => { + let address: string, payload: SignerPayloadJSON, pair: KeyringPair; + + beforeEach(async () => { + address = await createAccount(); + pair = keyring.getPair(address); + pair.decodePkcs8(password); + payload = { + address, + blockHash: '0xe1b1dda72998846487e4d858909d4f9a6bbd6e338e4588e5d809de16b1317b80', + blockNumber: '0x00000393', + era: '0x3601', + genesisHash: '0x242a54b35e1aad38f37b884eddeb71f6f9931b02fac27bf52dfb62ef754e5e62', + method: '0x040105fa8eaf04151687736326c9fea17e25fc5287613693c912909cb226aa4794f26a4882380100', + nonce: '0x0000000000000000', + signedExtensions: ['CheckSpecVersion', 'CheckTxVersion', 'CheckGenesis', 'CheckMortality', 'CheckNonce', 'CheckWeight', 'ChargeTransactionPayment'], + specVersion: '0x00000026', + tip: '0x00000000000000000000000000000000', + transactionVersion: '0x00000005', + version: 4 + }; + }); + + it('signs with default signed extensions', async () => { + const registry = new TypeRegistry(); + + registry.setSignedExtensions(payload.signedExtensions); + + const signatureExpected = registry + .createType('ExtrinsicPayload', payload, { version: payload.version }).sign(pair); + + // eslint-disable-next-line jest/valid-expect-in-promise + tabs.handle('1615191860871.5', 'pub(extrinsic.sign)', payload, 'http://localhost:3000', {} as chrome.runtime.Port) + .then((result) => { + // eslint-disable-next-line jest/no-conditional-expect + expect((result as ResponseSigning)?.signature).toEqual(signatureExpected.signature); + }).catch((err) => console.log(err)); + + const res = await extension.handle('1615192072290.7', 'pri(signing.approve.password)', { + id: state.allSignRequests[0].id, + password, + savePass: false + }, {} as chrome.runtime.Port); + + expect(res).toEqual(true); + }); + + it('signs with default signed extensions - ethereum', async () => { + const ethAddress = await createAccount('ethereum'); + const ethPair = keyring.getPair(ethAddress); + + ethPair.decodePkcs8(password); + const ethPayload: SignerPayloadJSON = { + address: ethAddress, + blockHash: '0xf9fc354edc3ff49f43d5e2c14e3c609a0c4ba469ed091edf893d672993dc9bc0', + blockNumber: '0x00000393', + era: '0x3601', + genesisHash: '0xf9fc354edc3ff49f43d5e2c14e3c609a0c4ba469ed091edf893d672993dc9bc0', + method: '0x03003cd0a705a2dc65e5b1e1205896baa2be8a07c6e0070010a5d4e8', + nonce: '0x00000000', + signedExtensions: [ + 'CheckSpecVersion', + 'CheckTxVersion', + 'CheckGenesis', + 'CheckMortality', + 'CheckNonce', + 'CheckWeight', + 'ChargeTransactionPayment' + ], + specVersion: '0x000003e9', + tip: '0x00000000000000000000000000000000', + transactionVersion: '0x00000002', + version: 4 + }; + const registry = new TypeRegistry(); + + registry.setSignedExtensions(payload.signedExtensions); + + const signatureExpected = registry + .createType('ExtrinsicPayload', ethPayload, { version: ethPayload.version }).sign(ethPair); + + // eslint-disable-next-line jest/valid-expect-in-promise + tabs.handle('1615191860871.5', 'pub(extrinsic.sign)', ethPayload, 'http://localhost:3000', {} as chrome.runtime.Port) + .then((result) => { + // eslint-disable-next-line jest/no-conditional-expect + expect((result as ResponseSigning)?.signature).toEqual(signatureExpected.signature); + }).catch((err) => console.log(err)); + + const res = await extension.handle('1615192072290.7', 'pri(signing.approve.password)', { + id: state.allSignRequests[0].id, + password, + savePass: false + }, {} as chrome.runtime.Port); + + expect(res).toEqual(true); + }); + + it('signs with user extensions, known types', async () => { + const types = {} as unknown as Record; + + const userExtensions = { + MyUserExtension: { + extrinsic: { + assetId: 'AssetId' + }, + payload: {} + } + } as unknown as ExtDef; + + const meta: MetadataDef = { + chain: 'Development', + color: '#191a2e', + genesisHash: '0x242a54b35e1aad38f37b884eddeb71f6f9931b02fac27bf52dfb62ef754e5e62', + icon: '', + specVersion: 38, + ss58Format: 0, + tokenDecimals: 12, + tokenSymbol: '', + types, + userExtensions + }; + + await state.saveMetadata(meta); + + const payload: SignerPayloadJSON = { + address, + blockHash: '0xe1b1dda72998846487e4d858909d4f9a6bbd6e338e4588e5d809de16b1317b80', + blockNumber: '0x00000393', + era: '0x3601', + genesisHash: '0x242a54b35e1aad38f37b884eddeb71f6f9931b02fac27bf52dfb62ef754e5e62', + method: '0x040105fa8eaf04151687736326c9fea17e25fc5287613693c912909cb226aa4794f26a4882380100', + nonce: '0x0000000000000000', + signedExtensions: ['MyUserExtension'], + specVersion: '0x00000026', + tip: '0x00000000000000000000000000000000', + transactionVersion: '0x00000005', + version: 4 + }; + + const registry = new TypeRegistry(); + + registry.setSignedExtensions(payload.signedExtensions, userExtensions); + registry.register(types); + + const signatureExpected = registry + .createType('ExtrinsicPayload', payload, { version: payload.version }).sign(pair); + + // eslint-disable-next-line jest/valid-expect-in-promise + tabs.handle('1615191860771.5', 'pub(extrinsic.sign)', payload, 'http://localhost:3000', {} as chrome.runtime.Port) + .then((result) => { + // eslint-disable-next-line jest/no-conditional-expect + expect((result as ResponseSigning)?.signature).toEqual(signatureExpected.signature); + }).catch((err) => console.log(err)); + + const res = await extension.handle('1615192062290.7', 'pri(signing.approve.password)', { + id: state.allSignRequests[0].id, + password, + savePass: false + }, {} as chrome.runtime.Port); + + expect(res).toEqual(true); + }); + + it('override default signed extension', async () => { + const types = { + FeeExchangeV1: { + assetId: 'Compact', + maxPayment: 'Compact' + }, + PaymentOptions: { + feeExchange: 'FeeExchangeV1', + tip: 'Compact' + } + } as unknown as Record; + + const userExtensions = { + ChargeTransactionPayment: { + extrinsic: { + transactionPayment: 'PaymentOptions' + }, + payload: {} + } + } as unknown as ExtDef; + + const meta: MetadataDef = { + chain: 'Development', + color: '#191a2e', + genesisHash: '0x242a54b35e1aad38f37b884eddeb71f6f9931b02fac27bf52dfb62ef754e5e62', + icon: '', + specVersion: 38, + ss58Format: 0, + tokenDecimals: 12, + tokenSymbol: '', + types, + userExtensions + }; + + await state.saveMetadata(meta); + + const registry = new TypeRegistry(); + + registry.setSignedExtensions(payload.signedExtensions, userExtensions); + registry.register(types); + + const signatureExpected = registry + .createType('ExtrinsicPayload', payload, { version: payload.version }).sign(pair); + + // eslint-disable-next-line jest/valid-expect-in-promise + tabs.handle('1615191860771.5', 'pub(extrinsic.sign)', payload, 'http://localhost:3000', {} as chrome.runtime.Port) + .then((result) => { + // eslint-disable-next-line jest/no-conditional-expect + expect((result as ResponseSigning)?.signature).toEqual(signatureExpected.signature); + }).catch((err) => console.log(err)); + + const res = await extension.handle('1615192062290.7', 'pri(signing.approve.password)', { + id: state.allSignRequests[0].id, + password, + savePass: false + }, {} as chrome.runtime.Port); + + expect(res).toEqual(true); + }); + + it('signs with user extensions, additional types', async () => { + const types = { + myCustomType: { + feeExchange: 'Compact', + tip: 'Compact' + } + } as unknown as Record; + + const userExtensions = { + MyUserExtension: { + extrinsic: { + myCustomType: 'myCustomType' + }, + payload: {} + } + } as unknown as ExtDef; + + const meta: MetadataDef = { + chain: 'Development', + color: '#191a2e', + genesisHash: '0x242a54b35e1aad38f37b884eddeb71f6f9931b02fac27bf52dfb62ef754e5e62', + icon: '', + specVersion: 38, + ss58Format: 0, + tokenDecimals: 12, + tokenSymbol: '', + types, + userExtensions + }; + + await state.saveMetadata(meta); + + const payload = { + address, + blockHash: '0xe1b1dda72998846487e4d858909d4f9a6bbd6e338e4588e5d809de16b1317b80', + blockNumber: '0x00000393', + era: '0x3601', + genesisHash: '0x242a54b35e1aad38f37b884eddeb71f6f9931b02fac27bf52dfb62ef754e5e62', + method: '0x040105fa8eaf04151687736326c9fea17e25fc5287613693c912909cb226aa4794f26a4882380100', + nonce: '0x0000000000000000', + signedExtensions: ['MyUserExtension', 'CheckTxVersion', 'CheckGenesis', 'CheckMortality', 'CheckNonce', 'CheckWeight', 'ChargeTransactionPayment'], + specVersion: '0x00000026', + tip: null, + transactionVersion: '0x00000005', + version: 4 + } as unknown as SignerPayloadJSON; + + const registry = new TypeRegistry(); + + registry.setSignedExtensions(payload.signedExtensions, userExtensions); + registry.register(types); + + const signatureExpected = registry + .createType('ExtrinsicPayload', payload, { version: payload.version }).sign(pair); + + // eslint-disable-next-line jest/valid-expect-in-promise + tabs.handle('1615191860771.5', 'pub(extrinsic.sign)', payload, 'http://localhost:3000', {} as chrome.runtime.Port) + .then((result) => { + // eslint-disable-next-line jest/no-conditional-expect + expect((result as ResponseSigning)?.signature).toEqual(signatureExpected.signature); + }).catch((err) => console.log(err)); + + const res = await extension.handle('1615192062290.7', 'pri(signing.approve.password)', { + id: state.allSignRequests[0].id, + password, + savePass: false + }, {} as chrome.runtime.Port); + + expect(res).toEqual(true); + }); + }); +}); diff --git a/packages/extension-base/src/background/handlers/Extension.ts b/packages/extension-base/src/background/handlers/Extension.ts new file mode 100644 index 0000000..6de9e2d --- /dev/null +++ b/packages/extension-base/src/background/handlers/Extension.ts @@ -0,0 +1,690 @@ +// Copyright 2019-2025 @pezkuwi/extension-base authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/* global chrome */ + +import type { MetadataDef } from '@pezkuwi/extension-inject/types'; +import type { KeyringPair, KeyringPair$Json, KeyringPair$Meta } from '@pezkuwi/keyring/types'; +import type { Registry, SignerPayloadJSON, SignerPayloadRaw } from '@pezkuwi/types/types'; +import type { SubjectInfo } from '@pezkuwi/ui-keyring/observable/types'; +import type { KeypairType } from '@pezkuwi/util-crypto/types'; +import type { AccountJson, AllowedPath, AuthorizeRequest, MessageTypes, MetadataRequest, RequestAccountBatchExport, RequestAccountChangePassword, RequestAccountCreateExternal, RequestAccountCreateHardware, RequestAccountCreateSuri, RequestAccountEdit, RequestAccountExport, RequestAccountForget, RequestAccountShow, RequestAccountTie, RequestAccountValidate, RequestActiveTabsUrlUpdate, RequestAuthorizeApprove, RequestBatchRestore, RequestDeriveCreate, RequestDeriveValidate, RequestJsonRestore, RequestMetadataApprove, RequestMetadataReject, RequestSeedCreate, RequestSeedValidate, RequestSigningApprovePassword, RequestSigningApproveSignature, RequestSigningCancel, RequestSigningIsLocked, RequestTypes, RequestUpdateAuthorizedAccounts, ResponseAccountExport, ResponseAccountsExport, ResponseAuthorizeList, ResponseDeriveValidate, ResponseJsonGetAccountInfo, ResponseSeedCreate, ResponseSeedValidate, ResponseSigningIsLocked, ResponseType, SigningRequest } from '../types.js'; +import type { AuthorizedAccountsDiff } from './State.js'; +import type State from './State.js'; + +import { ALLOWED_PATH, PASSWORD_EXPIRY_MS } from '@pezkuwi/extension-base/defaults'; +import { metadataExpand } from '@pezkuwi/extension-chains'; +import { TypeRegistry } from '@pezkuwi/types'; +import { keyring } from '@pezkuwi/ui-keyring'; +import { accounts as accountsObservable } from '@pezkuwi/ui-keyring/observable/accounts'; +import { assert, isHex } from '@pezkuwi/util'; +import { keyExtractSuri, mnemonicGenerate, mnemonicValidate } from '@pezkuwi/util-crypto'; + +import { withErrorLog } from './helpers.js'; +import { createSubscription, unsubscribe } from './subscriptions.js'; + +type CachedUnlocks = Record; + +const SEED_DEFAULT_LENGTH = 12; +const SEED_LENGTHS = [12, 15, 18, 21, 24]; +const ETH_DERIVE_DEFAULT = "/m/44'/60'/0'/0/0"; + +function getSuri (seed: string, type?: KeypairType): string { + return type === 'ethereum' + ? `${seed}${ETH_DERIVE_DEFAULT}` + : seed; +} + +function isJsonPayload (value: SignerPayloadJSON | SignerPayloadRaw): value is SignerPayloadJSON { + return (value as SignerPayloadJSON).genesisHash !== undefined; +} + +export default class Extension { + readonly #cachedUnlocks: CachedUnlocks; + + readonly #state: State; + + constructor (state: State) { + this.#cachedUnlocks = {}; + this.#state = state; + } + + private transformAccounts (accounts: SubjectInfo): AccountJson[] { + return Object.values(accounts).map(({ json: { address, meta }, type }): AccountJson => ({ + address, + isDefaultAuthSelected: this.#state.defaultAuthAccountSelection.includes(address), + ...meta, + type + })); + } + + private accountsCreateExternal ({ address, genesisHash, name }: RequestAccountCreateExternal): boolean { + keyring.addExternal(address, { genesisHash, name }); + + return true; + } + + private accountsCreateHardware ({ accountIndex, address, addressOffset, genesisHash, hardwareType, name, type }: RequestAccountCreateHardware): boolean { + keyring.addHardware(address, hardwareType, { accountIndex, addressOffset, genesisHash, name, type }); + + return true; + } + + private accountsCreateSuri ({ genesisHash, name, password, suri, type }: RequestAccountCreateSuri): boolean { + keyring.addUri(getSuri(suri, type), password, { genesisHash, name }, type); + + return true; + } + + private accountsChangePassword ({ address, newPass, oldPass }: RequestAccountChangePassword): boolean { + const pair = keyring.getPair(address); + + assert(pair, 'Unable to find pair'); + + try { + if (!pair.isLocked) { + pair.lock(); + } + + pair.decodePkcs8(oldPass); + } catch { + throw new Error('oldPass is invalid'); + } + + keyring.encryptAccount(pair, newPass); + + return true; + } + + private accountsEdit ({ address, name }: RequestAccountEdit): boolean { + const pair = keyring.getPair(address); + + assert(pair, 'Unable to find pair'); + + keyring.saveAccountMeta(pair, { ...pair.meta, name }); + + return true; + } + + private accountsExport ({ address, password }: RequestAccountExport): ResponseAccountExport { + return { exportedJson: keyring.backupAccount(keyring.getPair(address), password) }; + } + + private async accountsBatchExport ({ addresses, password }: RequestAccountBatchExport): Promise { + return { + exportedJson: await keyring.backupAccounts(addresses, password) + }; + } + + private async accountsForget ({ address }: RequestAccountForget): Promise { + const authorizedAccountsDiff: AuthorizedAccountsDiff = []; + + // cycle through authUrls and prepare the array of diff + Object.entries(this.#state.authUrls).forEach(([url, urlInfo]) => { + // Note that urlInfo.authorizedAccounts may be undefined if this website entry + // was created before the "account authorization per website" functionality was introduced + if (urlInfo.authorizedAccounts?.includes(address)) { + authorizedAccountsDiff.push([url, urlInfo.authorizedAccounts.filter((previousAddress) => previousAddress !== address)]); + } + }); + + await this.#state.updateAuthorizedAccounts(authorizedAccountsDiff); + + // cycle through default account selection for auth and remove any occurrence of the account + const newDefaultAuthAccounts = this.#state.defaultAuthAccountSelection.filter((defaultSelectionAddress) => defaultSelectionAddress !== address); + + await this.#state.updateDefaultAuthAccounts(newDefaultAuthAccounts); + + keyring.forgetAccount(address); + + return true; + } + + private refreshAccountPasswordCache (pair: KeyringPair): number { + const { address } = pair; + + const savedExpiry = this.#cachedUnlocks[address] || 0; + const remainingTime = savedExpiry - Date.now(); + + if (remainingTime < 0) { + this.#cachedUnlocks[address] = 0; + pair.lock(); + + return 0; + } + + return remainingTime; + } + + private accountsShow ({ address, isShowing }: RequestAccountShow): boolean { + const pair = keyring.getPair(address); + + assert(pair, 'Unable to find pair'); + + keyring.saveAccountMeta(pair, { ...pair.meta, isHidden: !isShowing }); + + return true; + } + + private accountsTie ({ address, genesisHash }: RequestAccountTie): boolean { + const pair = keyring.getPair(address); + + assert(pair, 'Unable to find pair'); + + keyring.saveAccountMeta(pair, { ...pair.meta, genesisHash }); + + return true; + } + + private accountsValidate ({ address, password }: RequestAccountValidate): boolean { + try { + keyring.backupAccount(keyring.getPair(address), password); + + return true; + } catch { + return false; + } + } + + private accountsSubscribe (id: string, port: chrome.runtime.Port): boolean { + const cb = createSubscription<'pri(accounts.subscribe)'>(id, port); + const subscription = accountsObservable.subject.subscribe((accounts: SubjectInfo): void => + cb(this.transformAccounts(accounts)) + ); + + port.onDisconnect.addListener((): void => { + unsubscribe(id); + subscription.unsubscribe(); + }); + + return true; + } + + private authorizeApprove ({ authorizedAccounts, id }: RequestAuthorizeApprove): boolean { + const queued = this.#state.getAuthRequest(id); + + assert(queued, 'Unable to find request'); + + const { resolve } = queued; + + resolve({ authorizedAccounts, result: true }); + + return true; + } + + private async authorizeUpdate ({ authorizedAccounts, url }: RequestUpdateAuthorizedAccounts): Promise { + return await this.#state.updateAuthorizedAccounts([[url, authorizedAccounts]]); + } + + private getAuthList (): ResponseAuthorizeList { + return { list: this.#state.authUrls }; + } + + // FIXME This looks very much like what we have in accounts + private authorizeSubscribe (id: string, port: chrome.runtime.Port): boolean { + const cb = createSubscription<'pri(authorize.requests)'>(id, port); + const subscription = this.#state.authSubject.subscribe((requests: AuthorizeRequest[]): void => + cb(requests) + ); + + port.onDisconnect.addListener((): void => { + unsubscribe(id); + subscription.unsubscribe(); + }); + + return true; + } + + private async metadataApprove ({ id }: RequestMetadataApprove): Promise { + const queued = this.#state.getMetaRequest(id); + + assert(queued, 'Unable to find request'); + + const { request, resolve } = queued; + + await this.#state.saveMetadata(request); + + resolve(true); + + return true; + } + + private metadataGet (genesisHash: string | null): MetadataDef | null { + return this.#state.knownMetadata.find((result) => result.genesisHash === genesisHash) || null; + } + + private metadataList (): MetadataDef[] { + return this.#state.knownMetadata; + } + + private metadataReject ({ id }: RequestMetadataReject): boolean { + const queued = this.#state.getMetaRequest(id); + + assert(queued, 'Unable to find request'); + + const { reject } = queued; + + reject(new Error('Rejected')); + + return true; + } + + private metadataSubscribe (id: string, port: chrome.runtime.Port): boolean { + const cb = createSubscription<'pri(metadata.requests)'>(id, port); + const subscription = this.#state.metaSubject.subscribe((requests: MetadataRequest[]): void => + cb(requests) + ); + + port.onDisconnect.addListener((): void => { + unsubscribe(id); + subscription.unsubscribe(); + }); + + return true; + } + + private jsonRestore ({ file, password }: RequestJsonRestore): void { + try { + keyring.restoreAccount(file, password); + } catch (error) { + throw new Error((error as Error).message); + } + } + + private batchRestore ({ file, password }: RequestBatchRestore): void { + try { + keyring.restoreAccounts(file, password); + } catch (error) { + throw new Error((error as Error).message); + } + } + + private jsonGetAccountInfo (json: KeyringPair$Json): ResponseJsonGetAccountInfo { + try { + const { address, meta: { genesisHash, name }, type } = keyring.createFromJson(json); + + return { + address, + genesisHash, + name, + type + } as ResponseJsonGetAccountInfo; + } catch (e) { + console.error(e); + throw new Error((e as Error).message); + } + } + + private seedCreate ({ length = SEED_DEFAULT_LENGTH, seed: _seed, type }: RequestSeedCreate): ResponseSeedCreate { + const seed = _seed || mnemonicGenerate(length); + + return { + address: keyring.createFromUri(getSuri(seed, type), {}, type).address, + seed + }; + } + + private seedValidate ({ suri, type }: RequestSeedValidate): ResponseSeedValidate { + const { phrase } = keyExtractSuri(suri); + + if (isHex(phrase)) { + assert(isHex(phrase, 256), 'Hex seed needs to be 256-bits'); + } else { + // sadly isHex detects as string, so we need a cast here + assert(SEED_LENGTHS.includes((phrase).split(' ').length), `Mnemonic needs to contain ${SEED_LENGTHS.join(', ')} words`); + assert(mnemonicValidate(phrase), 'Not a valid mnemonic seed'); + } + + return { + address: keyring.createFromUri(getSuri(suri, type), {}, type).address, + suri + }; + } + + private signingApprovePassword ({ id, password, savePass }: RequestSigningApprovePassword): boolean { + const queued = this.#state.getSignRequest(id); + + assert(queued, 'Unable to find request'); + + const { reject, request, resolve } = queued; + const pair = keyring.getPair(queued.account.address); + + if (!pair) { + reject(new Error('Unable to find pair')); + + return false; + } + + this.refreshAccountPasswordCache(pair); + + // if the keyring pair is locked, the password is needed + if (pair.isLocked && !password) { + reject(new Error('Password needed to unlock the account')); + } + + if (pair.isLocked) { + pair.decodePkcs8(password); + } + + // construct a new registry (avoiding pollution), between requests + let registry: Registry; + const { payload } = request; + + if (isJsonPayload(payload)) { + // Get the metadata for the genesisHash + const metadata = this.#state.knownMetadata.find(({ genesisHash }) => genesisHash === payload.genesisHash); + + if (metadata) { + // we have metadata, expand it and extract the info/registry + const expanded = metadataExpand(metadata, false); + + registry = expanded.registry; + registry.setSignedExtensions(payload.signedExtensions, expanded.definition.userExtensions); + } else { + // we have no metadata, create a new registry + registry = new TypeRegistry(); + registry.setSignedExtensions(payload.signedExtensions); + } + } else { + // for non-payload, just create a registry to use + registry = new TypeRegistry(); + } + + const result = request.sign(registry, pair); + + if (savePass) { + // unlike queued.account.address the following + // address is encoded with the default prefix + // which what is used for password caching mapping + this.#cachedUnlocks[pair.address] = Date.now() + PASSWORD_EXPIRY_MS; + } else { + pair.lock(); + } + + resolve({ + id, + ...result + }); + + return true; + } + + private signingApproveSignature ({ id, signature, signedTransaction }: RequestSigningApproveSignature): boolean { + const queued = this.#state.getSignRequest(id); + + assert(queued, 'Unable to find request'); + + const { resolve } = queued; + + resolve({ id, signature, signedTransaction }); + + return true; + } + + private signingCancel ({ id }: RequestSigningCancel): boolean { + const queued = this.#state.getSignRequest(id); + + assert(queued, 'Unable to find request'); + + const { reject } = queued; + + reject(new Error('Cancelled')); + + return true; + } + + private signingIsLocked ({ id }: RequestSigningIsLocked): ResponseSigningIsLocked { + const queued = this.#state.getSignRequest(id); + + assert(queued, 'Unable to find request'); + + const address = queued.request.payload.address; + const pair = keyring.getPair(address); + + assert(pair, 'Unable to find pair'); + + const remainingTime = this.refreshAccountPasswordCache(pair); + + return { + isLocked: pair.isLocked, + remainingTime + }; + } + + // FIXME This looks very much like what we have in authorization + private signingSubscribe (id: string, port: chrome.runtime.Port): boolean { + const cb = createSubscription<'pri(signing.requests)'>(id, port); + const subscription = this.#state.signSubject.subscribe((requests: SigningRequest[]): void => + cb(requests) + ); + + port.onDisconnect.addListener((): void => { + unsubscribe(id); + subscription.unsubscribe(); + }); + + return true; + } + + private windowOpen (path: AllowedPath): boolean { + const url = `${chrome.runtime.getURL('index.html')}#${path}`; + + if (!ALLOWED_PATH.includes(path)) { + console.error('Not allowed to open the url:', url); + + return false; + } + + withErrorLog(() => chrome.tabs.create({ url })); + + return true; + } + + private derive (parentAddress: string, suri: string, password: string, metadata: KeyringPair$Meta): KeyringPair { + const parentPair = keyring.getPair(parentAddress); + + try { + parentPair.decodePkcs8(password); + } catch { + throw new Error('invalid password'); + } + + try { + return parentPair.derive(suri, metadata); + } catch { + throw new Error(`"${suri}" is not a valid derivation path`); + } + } + + private derivationValidate ({ parentAddress, parentPassword, suri }: RequestDeriveValidate): ResponseDeriveValidate { + const childPair = this.derive(parentAddress, suri, parentPassword, {}); + + return { + address: childPair.address, + suri + }; + } + + private derivationCreate ({ genesisHash, name, parentAddress, parentPassword, password, suri }: RequestDeriveCreate): boolean { + const childPair = this.derive(parentAddress, suri, parentPassword, { + genesisHash, + name, + parentAddress, + suri + }); + + keyring.addPair(childPair, password); + + return true; + } + + private async removeAuthorization (url: string): Promise { + const remAuth = await this.#state.removeAuthorization(url); + + return { list: remAuth }; + } + + // Reject the authorization request and add the URL to the authorized list with no keys. + // The site will not prompt for re-authorization on future visits. + private rejectAuthRequest (id: string): void { + const queued = this.#state.getAuthRequest(id); + + assert(queued, 'Unable to find request'); + + const { reject } = queued; + + reject(new Error('Rejected')); + } + + // Cancel the authorization request and do not add the URL to the authorized list. + // The site will prompt for authorization on future visits. + private cancelAuthRequest (id: string): void { + const queued = this.#state.getAuthRequest(id); + + assert(queued, 'Unable to find request'); + + const { reject } = queued; + + reject(new Error('Cancelled')); + } + + private updateCurrentTabs ({ urls }: RequestActiveTabsUrlUpdate) { + this.#state.updateCurrentTabsUrl(urls); + } + + private getConnectedTabsUrl () { + return this.#state.getConnectedTabsUrl(); + } + + // Weird thought, the eslint override is not needed in Tabs + // eslint-disable-next-line @typescript-eslint/require-await + public async handle (id: string, type: TMessageType, request: RequestTypes[TMessageType], port?: chrome.runtime.Port): Promise> { + switch (type) { + case 'pri(authorize.approve)': + return this.authorizeApprove(request as RequestAuthorizeApprove); + + case 'pri(authorize.list)': + return this.getAuthList(); + + case 'pri(authorize.remove)': + return this.removeAuthorization(request as string); + + case 'pri(authorize.reject)': + return this.rejectAuthRequest(request as string); + + case 'pri(authorize.cancel)': + return this.cancelAuthRequest(request as string); + + case 'pri(authorize.requests)': + return port && this.authorizeSubscribe(id, port); + + case 'pri(authorize.update)': + return this.authorizeUpdate(request as RequestUpdateAuthorizedAccounts); + + case 'pri(accounts.create.external)': + return this.accountsCreateExternal(request as RequestAccountCreateExternal); + + case 'pri(accounts.create.hardware)': + return this.accountsCreateHardware(request as RequestAccountCreateHardware); + + case 'pri(accounts.create.suri)': + return this.accountsCreateSuri(request as RequestAccountCreateSuri); + + case 'pri(accounts.changePassword)': + return this.accountsChangePassword(request as RequestAccountChangePassword); + + case 'pri(accounts.edit)': + return this.accountsEdit(request as RequestAccountEdit); + + case 'pri(accounts.export)': + return this.accountsExport(request as RequestAccountExport); + + case 'pri(accounts.batchExport)': + return this.accountsBatchExport(request as RequestAccountBatchExport); + + case 'pri(accounts.forget)': + return this.accountsForget(request as RequestAccountForget); + + case 'pri(accounts.show)': + return this.accountsShow(request as RequestAccountShow); + + case 'pri(accounts.subscribe)': + return port && this.accountsSubscribe(id, port); + + case 'pri(accounts.tie)': + return this.accountsTie(request as RequestAccountTie); + + case 'pri(accounts.validate)': + return this.accountsValidate(request as RequestAccountValidate); + + case 'pri(metadata.approve)': + return await this.metadataApprove(request as RequestMetadataApprove); + + case 'pri(metadata.get)': + return this.metadataGet(request as string); + + case 'pri(metadata.list)': + return this.metadataList(); + + case 'pri(metadata.reject)': + return this.metadataReject(request as RequestMetadataReject); + + case 'pri(metadata.requests)': + return port && this.metadataSubscribe(id, port); + + case 'pri(activeTabsUrl.update)': + return this.updateCurrentTabs(request as RequestActiveTabsUrlUpdate); + + case 'pri(connectedTabsUrl.get)': + return this.getConnectedTabsUrl(); + + case 'pri(derivation.create)': + return this.derivationCreate(request as RequestDeriveCreate); + + case 'pri(derivation.validate)': + return this.derivationValidate(request as RequestDeriveValidate); + + case 'pri(json.restore)': + return this.jsonRestore(request as RequestJsonRestore); + + case 'pri(json.batchRestore)': + return this.batchRestore(request as RequestBatchRestore); + + case 'pri(json.account.info)': + return this.jsonGetAccountInfo(request as KeyringPair$Json); + + case 'pri(ping)': + return Promise.resolve(true); + + case 'pri(seed.create)': + return this.seedCreate(request as RequestSeedCreate); + + case 'pri(seed.validate)': + return this.seedValidate(request as RequestSeedValidate); + + case 'pri(settings.notification)': + return this.#state.setNotification(request as string); + + case 'pri(signing.approve.password)': + return this.signingApprovePassword(request as RequestSigningApprovePassword); + + case 'pri(signing.approve.signature)': + return this.signingApproveSignature(request as RequestSigningApproveSignature); + + case 'pri(signing.cancel)': + return this.signingCancel(request as RequestSigningCancel); + + case 'pri(signing.isLocked)': + return this.signingIsLocked(request as RequestSigningIsLocked); + + case 'pri(signing.requests)': + return port && this.signingSubscribe(id, port); + + case 'pri(window.open)': + return this.windowOpen(request as AllowedPath); + + default: + throw new Error(`Unable to handle message of type ${type}`); + } + } +} diff --git a/packages/extension-base/src/background/handlers/State.ts b/packages/extension-base/src/background/handlers/State.ts new file mode 100644 index 0000000..b60dbbb --- /dev/null +++ b/packages/extension-base/src/background/handlers/State.ts @@ -0,0 +1,664 @@ +// Copyright 2019-2025 @pezkuwi/extension-bg authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/* global chrome */ + +import type { MetadataDef, ProviderMeta } from '@pezkuwi/extension-inject/types'; +import type { JsonRpcResponse, ProviderInterface, ProviderInterfaceCallback } from '@pezkuwi/rpc-provider/types'; +import type { AccountJson, AuthorizeRequest, AuthUrlInfo, AuthUrls, MetadataRequest, RequestAuthorizeTab, RequestRpcSend, RequestRpcSubscribe, RequestRpcUnsubscribe, RequestSign, ResponseRpcListProviders, ResponseSigning, SigningRequest } from '../types.js'; + +import { BehaviorSubject } from 'rxjs'; + +import { addMetadata, knownMetadata } from '@pezkuwi/extension-chains'; +import { knownGenesis } from '@pezkuwi/networks/defaults'; +import { settings } from '@pezkuwi/ui-settings'; +import { assert } from '@pezkuwi/util'; + +import { MetadataStore } from '../../stores/index.js'; +import { getId } from '../../utils/getId.js'; +import { withErrorLog } from './helpers.js'; + +interface Resolver { + reject: (error: Error) => void; + resolve: (result: T) => void; +} + +interface AuthRequest extends Resolver { + id: string; + idStr: string; + request: RequestAuthorizeTab; + url: string; +} + +export type AuthorizedAccountsDiff = [url: string, authorizedAccounts: AuthUrlInfo['authorizedAccounts']][] + +interface MetaRequest extends Resolver { + id: string; + request: MetadataDef; + url: string; +} + +export interface AuthResponse { + result: boolean; + authorizedAccounts: string[]; +} + +// List of providers passed into constructor. This is the list of providers +// exposed by the extension. +type Providers = Record ProviderInterface; +}> + +interface SignRequest extends Resolver { + account: AccountJson; + id: string; + request: RequestSign; + url: string; +} + +const NOTIFICATION_URL = chrome.runtime.getURL('notification.html'); + +const POPUP_WINDOW_OPTS: chrome.windows.CreateData = { + focused: true, + height: 621, + left: 150, + top: 150, + type: 'popup', + url: NOTIFICATION_URL, + width: 560 +}; + +const NORMAL_WINDOW_OPTS: chrome.windows.CreateData = { + focused: true, + type: 'normal', + url: NOTIFICATION_URL +}; + +export enum NotificationOptions { + None, + Normal, + PopUp, +} + +const AUTH_URLS_KEY = 'authUrls'; +const DEFAULT_AUTH_ACCOUNTS = 'defaultAuthAccounts'; + +async function extractMetadata (store: MetadataStore): Promise { + await store.allMap(async (map): Promise => { + const knownEntries = Object.entries(knownGenesis); + const defs: Record = {}; + const removals: string[] = []; + + Object + .entries(map) + .forEach(([key, def]): void => { + const entry = knownEntries.find(([, hashes]) => hashes.includes(def.genesisHash)); + + if (entry) { + const [name, hashes] = entry; + const index = hashes.indexOf(def.genesisHash); + + // flatten the known metadata based on the genesis index + // (lower is better/newer) + if (!defs[name] || (defs[name].index > index)) { + if (defs[name]) { + // remove the old version of the metadata + removals.push(defs[name].key); + } + + defs[name] = { def, index, key }; + } + } else { + // this is not a known entry, so we will just apply it + defs[key] = { def, index: 0, key }; + } + }); + + for (const key of removals) { + await store.remove(key); + } + + Object.values(defs).forEach(({ def }) => addMetadata(def)); + }); +} + +export default class State { + #authUrls = new Map(); + + #lastRequestTimestamps = new Map(); + #maxEntries = 10; + #rateLimitInterval = 3000; // 3 seconds + + readonly #authRequests: Record = {}; + + readonly #metaStore = new MetadataStore(); + + // Map of providers currently injected in tabs + readonly #injectedProviders = new Map(); + + readonly #metaRequests: Record = {}; + + #notification = settings.notification; + + // Map of all providers exposed by the extension, they are retrievable by key + readonly #providers: Providers; + + readonly #signRequests: Record = {}; + + #windows: number[] = []; + + #connectedTabsUrl: string[] = []; + + public readonly authSubject: BehaviorSubject = new BehaviorSubject([]); + + public readonly metaSubject: BehaviorSubject = new BehaviorSubject([]); + + public readonly signSubject: BehaviorSubject = new BehaviorSubject([]); + + public readonly authUrlSubjects: Record> = {}; + + public defaultAuthAccountSelection: string[] = []; + + constructor (providers: Providers = {}, rateLimitInterval = 3000) { + assert(rateLimitInterval >= 0, 'Expects non-negative number for rateLimitInterval'); + this.#providers = providers; + this.#rateLimitInterval = rateLimitInterval; + } + + public async init () { + await extractMetadata(this.#metaStore); + // retrieve previously set authorizations + const storageAuthUrls: Record = await chrome.storage.local.get(AUTH_URLS_KEY); + const authString = storageAuthUrls?.[AUTH_URLS_KEY] || '{}'; + const previousAuth = JSON.parse(authString) as AuthUrls; + + this.#authUrls = new Map(Object.entries(previousAuth)); + + // Initialize authUrlSubjects for each URL + this.#authUrls.forEach((authInfo, url) => { + this.authUrlSubjects[url] = new BehaviorSubject(authInfo); + }); + + // retrieve previously set default auth accounts + const storageDefaultAuthAccounts: Record = await chrome.storage.local.get(DEFAULT_AUTH_ACCOUNTS); + const defaultAuthString: string = storageDefaultAuthAccounts?.[DEFAULT_AUTH_ACCOUNTS] || '[]'; + const previousDefaultAuth = JSON.parse(defaultAuthString) as string[]; + + this.defaultAuthAccountSelection = previousDefaultAuth; + } + + public get knownMetadata (): MetadataDef[] { + return knownMetadata(); + } + + public get numAuthRequests (): number { + return Object.keys(this.#authRequests).length; + } + + public get numMetaRequests (): number { + return Object.keys(this.#metaRequests).length; + } + + public get numSignRequests (): number { + return Object.keys(this.#signRequests).length; + } + + public get allAuthRequests (): AuthorizeRequest[] { + return Object + .values(this.#authRequests) + .map(({ id, request, url }): AuthorizeRequest => ({ id, request, url })); + } + + public get allMetaRequests (): MetadataRequest[] { + return Object + .values(this.#metaRequests) + .map(({ id, request, url }): MetadataRequest => ({ id, request, url })); + } + + public get allSignRequests (): SigningRequest[] { + return Object + .values(this.#signRequests) + .map(({ account, id, request, url }): SigningRequest => ({ account, id, request, url })); + } + + public get authUrls (): AuthUrls { + return Object.fromEntries(this.#authUrls); + } + + private popupClose (): void { + this.#windows.forEach((id: number) => + withErrorLog(() => chrome.windows.remove(id)) + ); + this.#windows = []; + } + + private popupOpen (): void { + this.#notification !== 'extension' && + chrome.windows.create( + this.#notification === 'window' + ? NORMAL_WINDOW_OPTS + : POPUP_WINDOW_OPTS, + (window): void => { + if (window) { + this.#windows.push(window.id || 0); + } + }); + } + + private authComplete = (id: string, resolve: (resValue: AuthResponse) => void, reject: (error: Error) => void): Resolver => { + const complete = async (authorizedAccounts: string[] = []) => { + const { idStr, request: { origin }, url } = this.#authRequests[id]; + + const strippedUrl = this.stripUrl(url); + + const authInfo: AuthUrlInfo = { + authorizedAccounts, + count: 0, + id: idStr, + origin, + url + }; + + this.#authUrls.set(strippedUrl, authInfo); + + if (!this.authUrlSubjects[strippedUrl]) { + this.authUrlSubjects[strippedUrl] = new BehaviorSubject(authInfo); + } else { + this.authUrlSubjects[strippedUrl].next(authInfo); + } + + await this.saveCurrentAuthList(); + await this.updateDefaultAuthAccounts(authorizedAccounts); + delete this.#authRequests[id]; + this.updateIconAuth(true); + }; + + return { + // eslint-disable-next-line @typescript-eslint/no-misused-promises + reject: async (error: Error): Promise => { + if (error.message === 'Cancelled') { + delete this.#authRequests[id]; + this.updateIconAuth(true); + reject(new Error('Connection request was cancelled by the user.')); + } else { + await complete(); + reject(new Error('Connection request was rejected by the user.')); + } + }, + // eslint-disable-next-line @typescript-eslint/no-misused-promises + resolve: async ({ authorizedAccounts, result }: AuthResponse): Promise => { + await complete(authorizedAccounts); + resolve({ authorizedAccounts, result }); + } + }; + }; + + /** + * @deprecated This method is deprecated in favor of {@link updateCurrentTabs} and will be removed in a future release. + */ + public udateCurrentTabsUrl (urls: string[]) { + this.updateCurrentTabsUrl(urls); + } + + public updateCurrentTabsUrl (urls: string[]) { + const connectedTabs = urls.map((url) => { + let strippedUrl = ''; + + // the assert in stripUrl may throw for new tabs with "chrome://newtab/" + try { + strippedUrl = this.stripUrl(url); + } catch (e) { + console.error(e); + } + + // return the stripped url only if this website is known + return !!strippedUrl && this.authUrls[strippedUrl] + ? strippedUrl + : undefined; + }) + .filter((value) => !!value) as string[]; + + this.#connectedTabsUrl = connectedTabs; + } + + public getConnectedTabsUrl () { + return this.#connectedTabsUrl; + } + + public deleteAuthRequest (requestId: string) { + delete this.#authRequests[requestId]; + this.updateIconAuth(true); + } + + private async saveCurrentAuthList () { + await chrome.storage.local.set({ [AUTH_URLS_KEY]: JSON.stringify(Object.fromEntries(this.#authUrls)) }); + } + + private async saveDefaultAuthAccounts () { + await chrome.storage.local.set({ [DEFAULT_AUTH_ACCOUNTS]: JSON.stringify(this.defaultAuthAccountSelection) }); + } + + public async updateDefaultAuthAccounts (newList: string[]) { + this.defaultAuthAccountSelection = newList; + await this.saveDefaultAuthAccounts(); + } + + private metaComplete = (id: string, resolve: (result: boolean) => void, reject: (error: Error) => void): Resolver => { + const complete = (): void => { + delete this.#metaRequests[id]; + this.updateIconMeta(true); + }; + + return { + reject: (error: Error): void => { + complete(); + reject(error); + }, + resolve: (result: boolean): void => { + complete(); + resolve(result); + } + }; + }; + + private signComplete = (id: string, resolve: (result: ResponseSigning) => void, reject: (error: Error) => void): Resolver => { + const complete = (): void => { + delete this.#signRequests[id]; + this.updateIconSign(true); + }; + + return { + reject: (error: Error): void => { + complete(); + reject(error); + }, + resolve: (result: ResponseSigning): void => { + complete(); + resolve(result); + } + }; + }; + + public stripUrl (url: string): string { + try { + const parsedUrl = new URL(url); + + if (!['http:', 'https:', 'ipfs:', 'ipns:'].includes(parsedUrl.protocol)) { + throw new Error(`Invalid protocol ${parsedUrl.protocol}`); + } + + // For ipfs/ipns which don't have a standard origin, we handle it differently. + if (parsedUrl.protocol === 'ipfs:' || parsedUrl.protocol === 'ipns:') { + // ipfs:// | ipns:// + return `${parsedUrl.protocol}//${parsedUrl.hostname}`; + } + + return parsedUrl.origin; + } catch (e) { + console.error(e); + throw new Error('Invalid URL'); + } + } + + private updateIcon (shouldClose?: boolean): void { + const authCount = this.numAuthRequests; + const metaCount = this.numMetaRequests; + const signCount = this.numSignRequests; + const text = ( + authCount + ? 'Auth' + : metaCount + ? 'Meta' + : (signCount ? `${signCount}` : '') + ); + + withErrorLog(() => chrome.action.setBadgeText({ text })); + + if (shouldClose && text === '') { + this.popupClose(); + } + } + + public async removeAuthorization (url: string): Promise { + const entry = this.#authUrls.get(url); + + assert(entry, `The source ${url} is not known`); + + this.#authUrls.delete(url); + await this.saveCurrentAuthList(); + + if (this.authUrlSubjects[url]) { + entry.authorizedAccounts = []; + this.authUrlSubjects[url].next(entry); + } + + return this.authUrls; + } + + private updateIconAuth (shouldClose?: boolean): void { + this.authSubject.next(this.allAuthRequests); + this.updateIcon(shouldClose); + } + + private updateIconMeta (shouldClose?: boolean): void { + this.metaSubject.next(this.allMetaRequests); + this.updateIcon(shouldClose); + } + + private updateIconSign (shouldClose?: boolean): void { + this.signSubject.next(this.allSignRequests); + this.updateIcon(shouldClose); + } + + public async updateAuthorizedAccounts (authorizedAccountsDiff: AuthorizedAccountsDiff): Promise { + authorizedAccountsDiff.forEach(([url, authorizedAccountDiff]) => { + const authInfo = this.#authUrls.get(url); + + if (authInfo) { + authInfo.authorizedAccounts = authorizedAccountDiff; + this.#authUrls.set(url, authInfo); + this.authUrlSubjects[url].next(authInfo); + } + }); + + await this.saveCurrentAuthList(); + } + + public async authorizeUrl (url: string, request: RequestAuthorizeTab): Promise { + const idStr = this.stripUrl(url); + + // Do not enqueue duplicate authorization requests. + const isDuplicate = Object + .values(this.#authRequests) + .some((request) => request.idStr === idStr); + + assert(!isDuplicate, `The source ${url} has a pending authorization request`); + + if (this.#authUrls.has(idStr)) { + // this url was seen in the past + const authInfo = this.#authUrls.get(idStr); + + assert(authInfo?.authorizedAccounts || authInfo?.isAllowed, `The source ${url} is not allowed to interact with this extension`); + + return { + authorizedAccounts: [], + result: false + }; + } + + return new Promise((resolve, reject): void => { + const id = getId(); + + this.#authRequests[id] = { + ...this.authComplete(id, resolve, reject), + id, + idStr, + request, + url + }; + + this.updateIconAuth(); + this.popupOpen(); + }); + } + + public ensureUrlAuthorized (url: string): boolean { + const entry = this.#authUrls.get(this.stripUrl(url)); + + assert(entry, `The source ${url} has not been enabled yet`); + + return true; + } + + public injectMetadata (url: string, request: MetadataDef): Promise { + return new Promise((resolve, reject): void => { + const id = getId(); + + this.#metaRequests[id] = { + ...this.metaComplete(id, resolve, reject), + id, + request, + url + }; + + this.updateIconMeta(); + this.popupOpen(); + }); + } + + public getAuthRequest (id: string): AuthRequest { + return this.#authRequests[id]; + } + + public getMetaRequest (id: string): MetaRequest { + return this.#metaRequests[id]; + } + + public getSignRequest (id: string): SignRequest { + return this.#signRequests[id]; + } + + // List all providers the extension is exposing + public rpcListProviders (): Promise { + return Promise.resolve(Object.keys(this.#providers).reduce((acc, key) => { + acc[key] = this.#providers[key].meta; + + return acc; + }, {} as ResponseRpcListProviders)); + } + + public rpcSend (request: RequestRpcSend, port: chrome.runtime.Port): Promise> { + const provider = this.#injectedProviders.get(port); + + assert(provider, 'Cannot call pub(rpc.subscribe) before provider is set'); + + return provider.send(request.method, request.params); + } + + // Start a provider, return its meta + public rpcStartProvider (key: string, port: chrome.runtime.Port): Promise { + assert(Object.keys(this.#providers).includes(key), `Provider ${key} is not exposed by extension`); + + if (this.#injectedProviders.get(port)) { + return Promise.resolve(this.#providers[key].meta); + } + + // Instantiate the provider + this.#injectedProviders.set(port, this.#providers[key].start()); + + // Close provider connection when page is closed + port.onDisconnect.addListener((): void => { + const provider = this.#injectedProviders.get(port); + + if (provider) { + withErrorLog(() => provider.disconnect()); + } + + this.#injectedProviders.delete(port); + }); + + return Promise.resolve(this.#providers[key].meta); + } + + public rpcSubscribe ({ method, params, type }: RequestRpcSubscribe, cb: ProviderInterfaceCallback, port: chrome.runtime.Port): Promise { + const provider = this.#injectedProviders.get(port); + + assert(provider, 'Cannot call pub(rpc.subscribe) before provider is set'); + + return provider.subscribe(type, method, params, cb); + } + + public rpcSubscribeConnected (_request: null, cb: ProviderInterfaceCallback, port: chrome.runtime.Port): void { + const provider = this.#injectedProviders.get(port); + + assert(provider, 'Cannot call pub(rpc.subscribeConnected) before provider is set'); + + cb(null, provider.isConnected); // Immediately send back current isConnected + provider.on('connected', () => cb(null, true)); + provider.on('disconnected', () => cb(null, false)); + } + + public rpcUnsubscribe (request: RequestRpcUnsubscribe, port: chrome.runtime.Port): Promise { + const provider = this.#injectedProviders.get(port); + + assert(provider, 'Cannot call pub(rpc.unsubscribe) before provider is set'); + + return provider.unsubscribe(request.type, request.method, request.subscriptionId); + } + + public async saveMetadata (meta: MetadataDef): Promise { + await this.#metaStore.set(meta.genesisHash, meta); + + addMetadata(meta); + } + + public setNotification (notification: string): boolean { + this.#notification = notification; + + return true; + } + + private handleSignRequest (origin: string) { + const now = Date.now(); + const lastTime = this.#lastRequestTimestamps.get(origin) || 0; + + if (now - lastTime < this.#rateLimitInterval) { + throw new Error('Rate limit exceeded. Try again later.'); + } + + // If we're about to exceed max entries, evict the oldest + if (!this.#lastRequestTimestamps.has(origin) && this.#lastRequestTimestamps.size >= this.#maxEntries) { + const oldestKey = this.#lastRequestTimestamps.keys().next().value; + + oldestKey && this.#lastRequestTimestamps.delete(oldestKey); + } + + this.#lastRequestTimestamps.set(origin, now); + } + + public sign (url: string, request: RequestSign, account: AccountJson): Promise { + const id = getId(); + + try { + this.handleSignRequest(url); + } catch (error) { + return Promise.reject(error); + } + + return new Promise((resolve, reject): void => { + this.#signRequests[id] = { + ...this.signComplete(id, resolve, reject), + account, + id, + request, + url + }; + + this.updateIconSign(); + this.popupOpen(); + }); + } +} diff --git a/packages/extension-base/src/background/handlers/Tabs.ts b/packages/extension-base/src/background/handlers/Tabs.ts new file mode 100644 index 0000000..6aae3d0 --- /dev/null +++ b/packages/extension-base/src/background/handlers/Tabs.ts @@ -0,0 +1,289 @@ +// Copyright 2019-2025 @pezkuwi/extension-base authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/* global chrome */ + +import type { InjectedAccount, InjectedMetadataKnown, MetadataDef, ProviderMeta } from '@pezkuwi/extension-inject/types'; +import type { KeyringPair } from '@pezkuwi/keyring/types'; +import type { JsonRpcResponse } from '@pezkuwi/rpc-provider/types'; +import type { SignerPayloadJSON, SignerPayloadRaw } from '@pezkuwi/types/types'; +import type { SubjectInfo } from '@pezkuwi/ui-keyring/observable/types'; +import type { AuthUrlInfo, MessageTypes, RequestAccountList, RequestAccountUnsubscribe, RequestAuthorizeTab, RequestRpcSend, RequestRpcSubscribe, RequestRpcUnsubscribe, RequestTypes, ResponseRpcListProviders, ResponseSigning, ResponseTypes, SubscriptionMessageTypes } from '../types.js'; +import type { AuthResponse } from './State.js'; +import type State from './State.js'; + +import { combineLatest, type Subscription } from 'rxjs'; + +import { checkIfDenied } from '@pezkuwi/phishing'; +import { keyring } from '@pezkuwi/ui-keyring'; +import { accounts as accountsObservable } from '@pezkuwi/ui-keyring/observable/accounts'; +import { assert, isNumber } from '@pezkuwi/util'; + +import { PHISHING_PAGE_REDIRECT } from '../../defaults.js'; +import { canDerive } from '../../utils/index.js'; +import RequestBytesSign from '../RequestBytesSign.js'; +import RequestExtrinsicSign from '../RequestExtrinsicSign.js'; +import { withErrorLog } from './helpers.js'; +import { createSubscription, unsubscribe } from './subscriptions.js'; + +interface AccountSub { + subscription: Subscription; + url: string; +} + +function transformAccounts (accounts: SubjectInfo, anyType = false): InjectedAccount[] { + return Object + .values(accounts) + .filter(({ json: { meta: { isHidden } } }) => !isHidden) + .filter(({ type }) => anyType ? true : canDerive(type)) + .sort((a, b) => (a.json.meta.whenCreated || 0) - (b.json.meta.whenCreated || 0)) + .map(({ json: { address, meta: { genesisHash, name } }, type }): InjectedAccount => ({ + address, + genesisHash, + name, + type + })); +} + +export default class Tabs { + readonly #accountSubs: Record = {}; + + readonly #state: State; + + constructor (state: State) { + this.#state = state; + } + + private filterForAuthorizedAccounts (accounts: InjectedAccount[], url: string): InjectedAccount[] { + const auth = this.#state.authUrls[this.#state.stripUrl(url)]; + + if (!auth) { + return []; + } + + return accounts.filter( + (allAcc) => + auth.authorizedAccounts + // we have a list, use it + ? auth.authorizedAccounts.includes(allAcc.address) + // if no authorizedAccounts and isAllowed return all - these are old converted urls + : auth.isAllowed + ); + } + + private authorize (url: string, request: RequestAuthorizeTab): Promise { + return this.#state.authorizeUrl(url, request); + } + + private accountsListAuthorized (url: string, { anyType }: RequestAccountList): InjectedAccount[] { + const transformedAccounts = transformAccounts(accountsObservable.subject.getValue(), anyType); + + return this.filterForAuthorizedAccounts(transformedAccounts, url); + } + + private accountsSubscribeAuthorized (url: string, id: string, port: chrome.runtime.Port): string { + const cb = createSubscription<'pub(accounts.subscribe)'>(id, port); + + const strippedUrl = this.#state.stripUrl(url); + + const authUrlObservable = this.#state.authUrlSubjects[strippedUrl]?.asObservable(); + + if (!authUrlObservable) { + console.error(`No authUrlSubject found for ${strippedUrl}`); + + return id; + } + + this.#accountSubs[id] = { + subscription: combineLatest([accountsObservable.subject, authUrlObservable]).subscribe(([accounts, _authUrlInfo]: [SubjectInfo, AuthUrlInfo]): void => { + const transformedAccounts = transformAccounts(accounts); + + cb(this.filterForAuthorizedAccounts(transformedAccounts, url)); + }), + url + }; + + port.onDisconnect.addListener((): void => { + this.accountsUnsubscribe(url, { id }); + }); + + return id; + } + + private accountsUnsubscribe (url: string, { id }: RequestAccountUnsubscribe): boolean { + const sub = this.#accountSubs[id]; + + if (!sub || sub.url !== url) { + return false; + } + + delete this.#accountSubs[id]; + + unsubscribe(id); + sub.subscription.unsubscribe(); + + return true; + } + + private getSigningPair (address: string): KeyringPair { + const pair = keyring.getPair(address); + + assert(pair, 'Unable to find keypair'); + + return pair; + } + + private bytesSign (url: string, request: SignerPayloadRaw): Promise { + const address = request.address; + const pair = this.getSigningPair(address); + + return this.#state.sign(url, new RequestBytesSign(request), { address, ...pair.meta }); + } + + private extrinsicSign (url: string, request: SignerPayloadJSON): Promise { + const address = request.address; + const pair = this.getSigningPair(address); + + return this.#state.sign(url, new RequestExtrinsicSign(request), { address, ...pair.meta }); + } + + private metadataProvide (url: string, request: MetadataDef): Promise { + return this.#state.injectMetadata(url, request); + } + + private metadataList (_url: string): InjectedMetadataKnown[] { + return this.#state.knownMetadata.map(({ genesisHash, specVersion }) => ({ + genesisHash, + specVersion + })); + } + + private rpcListProviders (): Promise { + return this.#state.rpcListProviders(); + } + + private rpcSend (request: RequestRpcSend, port: chrome.runtime.Port): Promise> { + return this.#state.rpcSend(request, port); + } + + private rpcStartProvider (key: string, port: chrome.runtime.Port): Promise { + return this.#state.rpcStartProvider(key, port); + } + + private async rpcSubscribe (request: RequestRpcSubscribe, id: string, port: chrome.runtime.Port): Promise { + const innerCb = createSubscription<'pub(rpc.subscribe)'>(id, port); + const cb = (_error: Error | null, data: SubscriptionMessageTypes['pub(rpc.subscribe)']): void => innerCb(data); + const subscriptionId = await this.#state.rpcSubscribe(request, cb, port); + + port.onDisconnect.addListener((): void => { + unsubscribe(id); + withErrorLog(() => this.rpcUnsubscribe({ ...request, subscriptionId }, port)); + }); + + return true; + } + + private rpcSubscribeConnected (request: null, id: string, port: chrome.runtime.Port): Promise { + const innerCb = createSubscription<'pub(rpc.subscribeConnected)'>(id, port); + const cb = (_error: Error | null, data: SubscriptionMessageTypes['pub(rpc.subscribeConnected)']): void => innerCb(data); + + this.#state.rpcSubscribeConnected(request, cb, port); + + port.onDisconnect.addListener((): void => { + unsubscribe(id); + }); + + return Promise.resolve(true); + } + + private async rpcUnsubscribe (request: RequestRpcUnsubscribe, port: chrome.runtime.Port): Promise { + return this.#state.rpcUnsubscribe(request, port); + } + + private redirectPhishingLanding (phishingWebsite: string): void { + const nonFragment = phishingWebsite.split('#')[0]; + const encodedWebsite = encodeURIComponent(nonFragment); + const url = `${chrome.runtime.getURL('index.html')}#${PHISHING_PAGE_REDIRECT}/${encodedWebsite}`; + + chrome.tabs.query({ url: nonFragment }, (tabs) => { + tabs + .map(({ id }) => id) + .filter((id): id is number => isNumber(id)) + .forEach((id) => + withErrorLog(() => chrome.tabs.update(id, { url })) + ); + }); + } + + private async redirectIfPhishing (url: string): Promise { + const isInDenyList = await checkIfDenied(url); + + if (isInDenyList) { + this.redirectPhishingLanding(url); + + return true; + } + + return false; + } + + public async handle (id: string, type: TMessageType, request: RequestTypes[TMessageType], url: string, port?: chrome.runtime.Port): Promise { + if (type === 'pub(phishing.redirectIfDenied)') { + return this.redirectIfPhishing(url); + } + + if (type !== 'pub(authorize.tab)') { + this.#state.ensureUrlAuthorized(url); + } + + switch (type) { + case 'pub(authorize.tab)': + return this.authorize(url, request as RequestAuthorizeTab); + + case 'pub(accounts.list)': + return this.accountsListAuthorized(url, request as RequestAccountList); + + case 'pub(accounts.subscribe)': + return port && this.accountsSubscribeAuthorized(url, id, port); + + case 'pub(accounts.unsubscribe)': + return this.accountsUnsubscribe(url, request as RequestAccountUnsubscribe); + + case 'pub(bytes.sign)': + return this.bytesSign(url, request as SignerPayloadRaw); + + case 'pub(extrinsic.sign)': + return this.extrinsicSign(url, request as SignerPayloadJSON); + + case 'pub(metadata.list)': + return this.metadataList(url); + + case 'pub(metadata.provide)': + return this.metadataProvide(url, request as MetadataDef); + + case 'pub(ping)': + return Promise.resolve(true); + + case 'pub(rpc.listProviders)': + return this.rpcListProviders(); + + case 'pub(rpc.send)': + return port && this.rpcSend(request as RequestRpcSend, port); + + case 'pub(rpc.startProvider)': + return port && this.rpcStartProvider(request as string, port); + + case 'pub(rpc.subscribe)': + return port && this.rpcSubscribe(request as RequestRpcSubscribe, id, port); + + case 'pub(rpc.subscribeConnected)': + return port && this.rpcSubscribeConnected(request as null, id, port); + + case 'pub(rpc.unsubscribe)': + return port && this.rpcUnsubscribe(request as RequestRpcUnsubscribe, port); + + default: + throw new Error(`Unable to handle message of type ${type}`); + } + } +} diff --git a/packages/extension-base/src/background/handlers/helpers.ts b/packages/extension-base/src/background/handlers/helpers.ts new file mode 100644 index 0000000..d57a99a --- /dev/null +++ b/packages/extension-base/src/background/handlers/helpers.ts @@ -0,0 +1,14 @@ +// Copyright 2019-2025 @pezkuwi/extension-base authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +export function withErrorLog (fn: () => unknown): void { + try { + const p = fn(); + + if (p && typeof p === 'object' && typeof (p as Promise).catch === 'function') { + (p as Promise).catch(console.error); + } + } catch (e) { + console.error(e); + } +} diff --git a/packages/extension-base/src/background/handlers/index.ts b/packages/extension-base/src/background/handlers/index.ts new file mode 100644 index 0000000..94417de --- /dev/null +++ b/packages/extension-base/src/background/handlers/index.ts @@ -0,0 +1,60 @@ +// Copyright 2019-2025 @pezkuwi/extension-base authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/* global chrome */ + +import type { MessageTypes, TransportRequestMessage } from '../types.js'; + +import { assert } from '@pezkuwi/util'; + +import { PORT_EXTENSION } from '../../defaults.js'; +import Extension from './Extension.js'; +import State from './State.js'; +import Tabs from './Tabs.js'; + +export { withErrorLog } from './helpers.js'; + +const state = new State(); + +await state.init(); +const extension = new Extension(state); +const tabs = new Tabs(state); + +export default function handler ({ id, message, request }: TransportRequestMessage, port?: chrome.runtime.Port, extensionPortName = PORT_EXTENSION): void { + const isExtension = !port || port?.name === extensionPortName; + const sender = port?.sender; + + if (!isExtension && !sender) { + throw new Error('Unable to extract message sender'); + } + + const from = isExtension + ? 'extension' + : sender?.url || sender?.tab?.url || ''; + const source = `${from}: ${id}: ${message}`; + + console.log(` [in] ${source}`); // :: ${JSON.stringify(request)}`); + + const promise = isExtension + ? extension.handle(id, message, request, port) + : tabs.handle(id, message, request, from, port); + + promise + .then((response: unknown): void => { + console.log(`[out] ${source}`); // :: ${JSON.stringify(response)}`); + + // between the start and the end of the promise, the user may have closed + // the tab, in which case port will be undefined + assert(port, 'Port has been disconnected'); + + port.postMessage({ id, response }); + }) + .catch((error: Error): void => { + console.log(`[err] ${source}:: ${error.message}`); + + // only send message back to port if it's still connected + if (port) { + port.postMessage({ error: error.message, id }); + } + }); +} diff --git a/packages/extension-base/src/background/handlers/subscriptions.ts b/packages/extension-base/src/background/handlers/subscriptions.ts new file mode 100644 index 0000000..f302cc5 --- /dev/null +++ b/packages/extension-base/src/background/handlers/subscriptions.ts @@ -0,0 +1,32 @@ +// Copyright 2019-2025 @pezkuwi/extension-base authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/* global chrome */ + +import type { MessageTypesWithSubscriptions, SubscriptionMessageTypes } from '../types.js'; + +type Subscriptions = Record; + +const subscriptions: Subscriptions = {}; + +// return a subscription callback, that will send the data to the caller via the port +export function createSubscription (id: string, port: chrome.runtime.Port): (data: SubscriptionMessageTypes[TMessageType]) => void { + subscriptions[id] = port; + + return (subscription: unknown): void => { + if (subscriptions[id]) { + port.postMessage({ id, subscription }); + } + }; +} + +// clear a previous subscriber +export function unsubscribe (id: string): void { + if (subscriptions[id]) { + console.log(`Unsubscribing from ${id}`); + + delete subscriptions[id]; + } else { + console.error(`Unable to unsubscribe from ${id}`); + } +} diff --git a/packages/extension-base/src/background/index.ts b/packages/extension-base/src/background/index.ts new file mode 100644 index 0000000..b5698da --- /dev/null +++ b/packages/extension-base/src/background/index.ts @@ -0,0 +1,4 @@ +// Copyright 2019-2025 @pezkuwi/extension-base authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +export { default as handlers, withErrorLog } from './handlers/index.js'; diff --git a/packages/extension-base/src/background/types.ts b/packages/extension-base/src/background/types.ts new file mode 100644 index 0000000..113f9f0 --- /dev/null +++ b/packages/extension-base/src/background/types.ts @@ -0,0 +1,432 @@ +// Copyright 2019-2025 @pezkuwi/extension-base authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/* eslint-disable no-use-before-define */ + +import type { InjectedAccount, InjectedMetadataKnown, MetadataDef, ProviderList, ProviderMeta } from '@pezkuwi/extension-inject/types'; +import type { KeyringPair, KeyringPair$Json, KeyringPair$Meta } from '@pezkuwi/keyring/types'; +import type { JsonRpcResponse } from '@pezkuwi/rpc-provider/types'; +import type { Registry, SignerPayloadJSON, SignerPayloadRaw } from '@pezkuwi/types/types'; +import type { KeyringPairs$Json } from '@pezkuwi/ui-keyring/types'; +import type { HexString } from '@pezkuwi/util/types'; +import type { KeypairType } from '@pezkuwi/util-crypto/types'; +import type { ALLOWED_PATH } from '../defaults.js'; +import type { AuthResponse } from './handlers/State.js'; + +type KeysWithDefinedValues = { + [K in keyof T]: T[K] extends undefined ? never : K +}[keyof T]; + +type NoUndefinedValues = { + [K in KeysWithDefinedValues]: T[K] +}; + +type IsNull = { [K1 in Exclude]: T[K1] } & T[K] extends null ? K : never; + +type NullKeys = { [K in keyof T]: IsNull }[keyof T]; + +export type AuthUrls = Record; + +export interface AuthUrlInfo { + count: number; + id: string; + // this is from pre-0.44.1 + isAllowed?: boolean; + origin: string; + url: string; + authorizedAccounts: string[]; +} + +export type SeedLengths = 12 | 24; + +export interface AccountJson extends KeyringPair$Meta { + address: string; +} + +export type AccountWithChildren = AccountJson & { + children?: AccountWithChildren[]; +} + +export interface AccountsContext { + accounts: AccountJson[]; + hierarchy: AccountWithChildren[]; + master?: AccountJson; + selectedAccounts?: AccountJson['address'][]; + setSelectedAccounts?: (address: AccountJson['address'][]) => void; +} + +export interface AuthorizeRequest { + id: string; + request: RequestAuthorizeTab; + url: string; +} + +export interface MetadataRequest { + id: string; + request: MetadataDef; + url: string; +} + +export interface SigningRequest { + account: AccountJson; + id: string; + request: RequestSign; + url: string; +} + +export type ConnectedTabsUrlResponse = string[] + +// [MessageType]: [RequestType, ResponseType, SubscriptionMessageType?] +export interface RequestSignatures { + // private/internal requests, i.e. from a popup + 'pri(accounts.create.external)': [RequestAccountCreateExternal, boolean]; + 'pri(accounts.create.hardware)': [RequestAccountCreateHardware, boolean]; + 'pri(accounts.create.suri)': [RequestAccountCreateSuri, boolean]; + 'pri(accounts.edit)': [RequestAccountEdit, boolean]; + 'pri(accounts.export)': [RequestAccountExport, ResponseAccountExport]; + 'pri(accounts.batchExport)': [RequestAccountBatchExport, ResponseAccountsExport] + 'pri(accounts.forget)': [RequestAccountForget, boolean]; + 'pri(accounts.list)': [RequestAccountList, InjectedAccount[]]; + 'pri(accounts.show)': [RequestAccountShow, boolean]; + 'pri(accounts.tie)': [RequestAccountTie, boolean]; + 'pri(accounts.subscribe)': [RequestAccountSubscribe, boolean, AccountJson[]]; + 'pri(accounts.validate)': [RequestAccountValidate, boolean]; + 'pri(accounts.changePassword)': [RequestAccountChangePassword, boolean]; + 'pri(authorize.approve)': [RequestAuthorizeApprove, boolean]; + 'pri(authorize.list)': [null, ResponseAuthorizeList]; + 'pri(authorize.requests)': [RequestAuthorizeSubscribe, boolean, AuthorizeRequest[]]; + 'pri(authorize.remove)': [string, ResponseAuthorizeList]; + 'pri(authorize.reject)': [string, void]; + 'pri(authorize.cancel)': [string, void]; + 'pri(authorize.update)': [RequestUpdateAuthorizedAccounts, void]; + 'pri(activeTabsUrl.update)': [RequestActiveTabsUrlUpdate, void]; + 'pri(connectedTabsUrl.get)': [null, ConnectedTabsUrlResponse]; + 'pri(derivation.create)': [RequestDeriveCreate, boolean]; + 'pri(derivation.validate)': [RequestDeriveValidate, ResponseDeriveValidate]; + 'pri(json.restore)': [RequestJsonRestore, void]; + 'pri(json.batchRestore)': [RequestBatchRestore, void]; + 'pri(json.account.info)': [KeyringPair$Json, ResponseJsonGetAccountInfo]; + 'pri(metadata.approve)': [RequestMetadataApprove, boolean]; + 'pri(metadata.get)': [string | null, MetadataDef | null]; + 'pri(metadata.reject)': [RequestMetadataReject, boolean]; + 'pri(metadata.requests)': [RequestMetadataSubscribe, boolean, MetadataRequest[]]; + 'pri(metadata.list)': [null, MetadataDef[]]; + 'pri(ping)': [null, boolean]; + 'pri(seed.create)': [RequestSeedCreate, ResponseSeedCreate]; + 'pri(seed.validate)': [RequestSeedValidate, ResponseSeedValidate]; + 'pri(settings.notification)': [string, boolean]; + 'pri(signing.approve.password)': [RequestSigningApprovePassword, boolean]; + 'pri(signing.approve.signature)': [RequestSigningApproveSignature, boolean]; + 'pri(signing.cancel)': [RequestSigningCancel, boolean]; + 'pri(signing.isLocked)': [RequestSigningIsLocked, ResponseSigningIsLocked]; + 'pri(signing.requests)': [RequestSigningSubscribe, boolean, SigningRequest[]]; + 'pri(window.open)': [AllowedPath, boolean]; + // public/external requests, i.e. from a page + 'pub(accounts.list)': [RequestAccountList, InjectedAccount[]]; + 'pub(accounts.subscribe)': [RequestAccountSubscribe, string, InjectedAccount[]]; + 'pub(accounts.unsubscribe)': [RequestAccountUnsubscribe, boolean]; + 'pub(authorize.tab)': [RequestAuthorizeTab, Promise]; + 'pub(bytes.sign)': [SignerPayloadRaw, ResponseSigning]; + 'pub(extrinsic.sign)': [SignerPayloadJSON, ResponseSigning]; + 'pub(metadata.list)': [null, InjectedMetadataKnown[]]; + 'pub(metadata.provide)': [MetadataDef, boolean]; + 'pub(phishing.redirectIfDenied)': [null, boolean]; + 'pub(ping)': [null, boolean]; + 'pub(rpc.listProviders)': [void, ResponseRpcListProviders]; + 'pub(rpc.send)': [RequestRpcSend, JsonRpcResponse]; + 'pub(rpc.startProvider)': [string, ProviderMeta]; + 'pub(rpc.subscribe)': [RequestRpcSubscribe, number, JsonRpcResponse]; + 'pub(rpc.subscribeConnected)': [null, boolean, boolean]; + 'pub(rpc.unsubscribe)': [RequestRpcUnsubscribe, boolean]; +} + +export type MessageTypes = keyof RequestSignatures; + +// Requests + +export type RequestTypes = { + [MessageType in keyof RequestSignatures]: RequestSignatures[MessageType][0] +}; + +export type MessageTypesWithNullRequest = NullKeys + +export interface TransportRequestMessage { + id: string; + message: TMessageType; + origin: string; + request: RequestTypes[TMessageType]; +} + +export interface RequestAuthorizeTab { + origin: string; +} + +export interface RequestAuthorizeApprove { + id: string; + authorizedAccounts: string[] +} + +export interface RequestUpdateAuthorizedAccounts { + url: string; + authorizedAccounts: string[] +} + +export type RequestAuthorizeSubscribe = null; + +export interface RequestMetadataApprove { + id: string; +} + +export interface RequestMetadataReject { + id: string; +} + +export type RequestMetadataSubscribe = null; + +export interface RequestAccountCreateExternal { + address: string; + genesisHash?: HexString | null; + name: string; +} + +export interface RequestAccountCreateSuri { + name: string; + genesisHash?: HexString | null; + password: string; + suri: string; + type?: KeypairType; +} + +export interface RequestAccountCreateHardware { + accountIndex: number; + address: string; + addressOffset: number; + genesisHash: HexString; + hardwareType: string; + name: string; + type: KeypairType; +} + +export interface RequestAccountChangePassword { + address: string; + oldPass: string; + newPass: string; +} + +export interface RequestAccountEdit { + address: string; + genesisHash?: HexString | null; + name: string; +} + +export interface RequestAccountForget { + address: string; +} + +export interface RequestAccountShow { + address: string; + isShowing: boolean; +} + +export interface RequestAccountTie { + address: string; + genesisHash: HexString | null; +} + +export interface RequestAccountValidate { + address: string; + password: string; +} + +export interface RequestDeriveCreate { + name: string; + genesisHash?: HexString | null; + suri: string; + parentAddress: string; + parentPassword: string; + password: string; +} + +export interface RequestDeriveValidate { + suri: string; + parentAddress: string; + parentPassword: string; +} + +export interface RequestAccountExport { + address: string; + password: string; +} + +export interface RequestAccountBatchExport { + addresses: string[]; + password: string; +} + +export interface RequestAccountList { + anyType?: boolean; +} + +export type RequestAccountSubscribe = null; + +export interface RequestActiveTabsUrlUpdate { + urls: string[]; +} + +export interface RequestAccountUnsubscribe { + id: string; +} + +export interface RequestRpcSend { + method: string; + params: unknown[]; +} + +export interface RequestRpcSubscribe extends RequestRpcSend { + type: string; +} + +export interface RequestRpcUnsubscribe { + method: string; + subscriptionId: number | string; + type: string; +} + +export interface RequestSigningApprovePassword { + id: string; + password?: string; + savePass: boolean; +} + +export interface RequestSigningApproveSignature { + id: string; + signature: HexString; + signedTransaction?: HexString; +} + +export interface RequestSigningCancel { + id: string; +} + +export interface RequestSigningIsLocked { + id: string; +} + +export interface ResponseSigningIsLocked { + isLocked: boolean; + remainingTime: number; +} + +export type RequestSigningSubscribe = null; + +export interface RequestSeedCreate { + length?: SeedLengths; + seed?: string; + type?: KeypairType; +} + +export interface RequestSeedValidate { + suri: string; + type?: KeypairType; +} + +// Responses + +export type ResponseTypes = { + [MessageType in keyof RequestSignatures]: RequestSignatures[MessageType][1] +}; + +export type ResponseType = RequestSignatures[TMessageType][1]; + +interface TransportResponseMessageSub { + error?: string; + id: string; + response?: ResponseTypes[TMessageType]; + subscription?: SubscriptionMessageTypes[TMessageType]; +} + +interface TransportResponseMessageNoSub { + error?: string; + id: string; + response?: ResponseTypes[TMessageType]; +} + +export type TransportResponseMessage = + TMessageType extends MessageTypesWithNoSubscriptions + ? TransportResponseMessageNoSub + : TMessageType extends MessageTypesWithSubscriptions + ? TransportResponseMessageSub + : never; + +export interface ResponseSigning { + id: string; + signature: HexString; + signedTransaction?: HexString; +} + +export interface ResponseDeriveValidate { + address: string; + suri: string; +} + +export interface ResponseSeedCreate { + address: string; + seed: string; +} + +export interface ResponseSeedValidate { + address: string; + suri: string; +} + +export interface ResponseAccountExport { + exportedJson: KeyringPair$Json; +} + +export interface ResponseAccountsExport { + exportedJson: KeyringPairs$Json; +} + +export type ResponseRpcListProviders = ProviderList; + +// Subscriptions + +export type SubscriptionMessageTypes = NoUndefinedValues<{ + [MessageType in keyof RequestSignatures]: RequestSignatures[MessageType][2] +}>; + +export type MessageTypesWithSubscriptions = keyof SubscriptionMessageTypes; +export type MessageTypesWithNoSubscriptions = Exclude + +export interface RequestSign { + readonly payload: SignerPayloadJSON | SignerPayloadRaw; + + sign (registry: Registry, pair: KeyringPair): { signature: HexString }; +} + +export interface RequestJsonRestore { + file: KeyringPair$Json; + password: string; +} + +export interface RequestBatchRestore { + file: KeyringPairs$Json; + password: string; +} + +export interface ResponseJsonRestore { + error: string | null; +} + +export type AllowedPath = typeof ALLOWED_PATH[number]; + +export interface ResponseJsonGetAccountInfo { + address: string; + name: string; + genesisHash: HexString; + type: KeypairType; +} + +export interface ResponseAuthorizeList { + list: AuthUrls; +} diff --git a/packages/extension-base/src/bundle.ts b/packages/extension-base/src/bundle.ts new file mode 100644 index 0000000..15ba5b4 --- /dev/null +++ b/packages/extension-base/src/bundle.ts @@ -0,0 +1,4 @@ +// Copyright 2019-2025 @pezkuwi/extension-base authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +export { packageInfo } from './packageInfo.js'; diff --git a/packages/extension-base/src/defaults.ts b/packages/extension-base/src/defaults.ts new file mode 100644 index 0000000..dc35e63 --- /dev/null +++ b/packages/extension-base/src/defaults.ts @@ -0,0 +1,26 @@ +// Copyright 2019-2025 @pezkuwi/extension-base authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +// this _must_ be changed for each extension +export const EXTENSION_PREFIX = process.env['EXTENSION_PREFIX'] || ''; + +if (!EXTENSION_PREFIX && !process.env['PORT_PREFIX']) { + throw new Error('CRITICAL: The extension does not define an own EXTENSION_PREFIX environment variable as part of the build, this is required to ensure that messages are not shared between extensions. Failure to do so will yield messages sent to multiple extensions.'); +} + +const PORT_PREFIX = `${EXTENSION_PREFIX || 'unknown'}-${process.env['PORT_PREFIX'] || 'unknown'}`; + +export const PORT_CONTENT = `${PORT_PREFIX}-content`; +export const PORT_EXTENSION = `${PORT_PREFIX}-extension`; + +export const MESSAGE_ORIGIN_PAGE = `${PORT_PREFIX}-page`; +export const MESSAGE_ORIGIN_CONTENT = `${PORT_PREFIX}-content`; + +export const ALLOWED_PATH = ['/', '/account/import-ledger', '/account/restore-json'] as const; + +export const PASSWORD_EXPIRY_MIN = 15; +export const PASSWORD_EXPIRY_MS = PASSWORD_EXPIRY_MIN * 60 * 1000; + +export const PHISHING_PAGE_REDIRECT = '/phishing-page-detected'; + +// console.log(`Extension is sending and receiving messages on ${PORT_PREFIX}-*`); diff --git a/packages/extension-base/src/index.ts b/packages/extension-base/src/index.ts new file mode 100644 index 0000000..f6a54ff --- /dev/null +++ b/packages/extension-base/src/index.ts @@ -0,0 +1,7 @@ +// Copyright 2019-2025 @pezkuwi/extension-base authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +// Since we inject into pages, we skip this +// import './detectPackage'; + +export * from './bundle.js'; diff --git a/packages/extension-base/src/packageDetect.ts b/packages/extension-base/src/packageDetect.ts new file mode 100644 index 0000000..f05238e --- /dev/null +++ b/packages/extension-base/src/packageDetect.ts @@ -0,0 +1,14 @@ +// Copyright 2017-2025 @pezkuwi/extension-base authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +// Do not edit, auto-generated by @pezkuwi/dev +// (packageInfo imports will be kept as-is, user-editable) + +import { packageInfo as chainsInfo } from '@pezkuwi/extension-chains/packageInfo'; +import { packageInfo as dappInfo } from '@pezkuwi/extension-dapp/packageInfo'; +import { packageInfo as injectInfo } from '@pezkuwi/extension-inject/packageInfo'; +import { detectPackage } from '@pezkuwi/util'; + +import { packageInfo } from './packageInfo.js'; + +detectPackage(packageInfo, null, [chainsInfo, dappInfo, injectInfo]); diff --git a/packages/extension-base/src/packageInfo.ts b/packages/extension-base/src/packageInfo.ts new file mode 100644 index 0000000..d7da5ca --- /dev/null +++ b/packages/extension-base/src/packageInfo.ts @@ -0,0 +1,6 @@ +// Copyright 2017-2025 @pezkuwi/extension-base authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +// Do not edit, auto-generated by @pezkuwi/dev + +export const packageInfo = { name: '@pezkuwi/extension-base', path: 'auto', type: 'auto', version: '0.62.6' }; diff --git a/packages/extension-base/src/page/Accounts.ts b/packages/extension-base/src/page/Accounts.ts new file mode 100644 index 0000000..27ab5ac --- /dev/null +++ b/packages/extension-base/src/page/Accounts.ts @@ -0,0 +1,33 @@ +// Copyright 2019-2025 @pezkuwi/extension-base authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { InjectedAccount, InjectedAccounts, Unsubcall } from '@pezkuwi/extension-inject/types'; +import type { SendRequest } from './types.js'; + +// External to class, this.# is not private enough (yet) +let sendRequest: SendRequest; + +export default class Accounts implements InjectedAccounts { + constructor (_sendRequest: SendRequest) { + sendRequest = _sendRequest; + } + + public get (anyType?: boolean): Promise { + return sendRequest('pub(accounts.list)', { anyType }); + } + + public subscribe (cb: (accounts: InjectedAccount[]) => unknown): Unsubcall { + let id: string | null = null; + + sendRequest('pub(accounts.subscribe)', null, cb) + .then((subId): void => { + id = subId; + }) + .catch(console.error); + + return (): void => { + id && sendRequest('pub(accounts.unsubscribe)', { id }) + .catch(console.error); + }; + } +} diff --git a/packages/extension-base/src/page/Injected.ts b/packages/extension-base/src/page/Injected.ts new file mode 100644 index 0000000..2fe54a0 --- /dev/null +++ b/packages/extension-base/src/page/Injected.ts @@ -0,0 +1,33 @@ +// Copyright 2019-2025 @pezkuwi/extension-base authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { Injected } from '@pezkuwi/extension-inject/types'; +import type { SendRequest } from './types.js'; + +import Accounts from './Accounts.js'; +import Metadata from './Metadata.js'; +import PostMessageProvider from './PostMessageProvider.js'; +import Signer from './Signer.js'; + +export default class implements Injected { + public readonly accounts: Accounts; + + public readonly metadata: Metadata; + + public readonly provider: PostMessageProvider; + + public readonly signer: Signer; + + constructor (sendRequest: SendRequest) { + this.accounts = new Accounts(sendRequest); + this.metadata = new Metadata(sendRequest); + this.provider = new PostMessageProvider(sendRequest); + this.signer = new Signer(sendRequest); + + setInterval((): void => { + sendRequest('pub(ping)', null).catch((): void => { + console.error('Extension unavailable, ping failed'); + }); + }, 5_000 + Math.floor(Math.random() * 5_000)); + } +} diff --git a/packages/extension-base/src/page/Metadata.ts b/packages/extension-base/src/page/Metadata.ts new file mode 100644 index 0000000..fbad168 --- /dev/null +++ b/packages/extension-base/src/page/Metadata.ts @@ -0,0 +1,22 @@ +// Copyright 2019-2025 @pezkuwi/extension-base authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { InjectedMetadata, InjectedMetadataKnown, MetadataDef } from '@pezkuwi/extension-inject/types'; +import type { SendRequest } from './types.js'; + +// External to class, this.# is not private enough (yet) +let sendRequest: SendRequest; + +export default class Metadata implements InjectedMetadata { + constructor (_sendRequest: SendRequest) { + sendRequest = _sendRequest; + } + + public get (): Promise { + return sendRequest('pub(metadata.list)'); + } + + public provide (definition: MetadataDef): Promise { + return sendRequest('pub(metadata.provide)', definition); + } +} diff --git a/packages/extension-base/src/page/PostMessageProvider.ts b/packages/extension-base/src/page/PostMessageProvider.ts new file mode 100644 index 0000000..b06b02d --- /dev/null +++ b/packages/extension-base/src/page/PostMessageProvider.ts @@ -0,0 +1,182 @@ +// Copyright 2019-2025 @pezkuwi/extension-base authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { InjectedProvider, ProviderList, ProviderMeta } from '@pezkuwi/extension-inject/types'; +import type { ProviderInterfaceEmitCb, ProviderInterfaceEmitted } from '@pezkuwi/rpc-provider/types'; +import type { AnyFunction } from '@pezkuwi/types/types'; +import type { SendRequest } from './types.js'; + +import { EventEmitter } from 'eventemitter3'; + +import { isUndefined, logger } from '@pezkuwi/util'; + +const l = logger('PostMessageProvider'); + +type CallbackHandler = (error?: null | Error, value?: unknown) => void; + +// Same as https://github.com/polkadot-js/api/blob/57ca9a9c3204339e1e1f693fcacc33039868dc27/packages/rpc-provider/src/ws/Provider.ts#L17 +interface SubscriptionHandler { + callback: CallbackHandler; + type: string; +} + +// External to class, this.# is not private enough (yet) +let sendRequest: SendRequest; + +/** + * @name PostMessageProvider + * + * @description Extension provider to be used by dapps + */ +export default class PostMessageProvider implements InjectedProvider { + readonly #eventemitter: EventEmitter; + + // Whether or not the actual extension background provider is connected + #isConnected = false; + + // Subscription IDs are (historically) not guaranteed to be globally unique; + // only unique for a given subscription method; which is why we identify + // the subscriptions based on subscription id + type + readonly #subscriptions: Record = {}; // {[(type,subscriptionId)]: callback} + + /** + * @param {function} sendRequest The function to be called to send requests to the node + * @param {function} subscriptionNotificationHandler Channel for receiving subscription messages + */ + public constructor (_sendRequest: SendRequest) { + this.#eventemitter = new EventEmitter(); + + sendRequest = _sendRequest; + } + + public get isClonable (): boolean { + return !!true; + } + + /** + * @description Returns a clone of the object + */ + public clone (): PostMessageProvider { + return new PostMessageProvider(sendRequest); + } + + /** + * @description Manually disconnect from the connection, clearing autoconnect logic + */ + // eslint-disable-next-line @typescript-eslint/require-await + public async connect (): Promise { + // FIXME This should see if the extension's state's provider can disconnect + console.error('PostMessageProvider.disconnect() is not implemented.'); + } + + /** + * @description Manually disconnect from the connection, clearing autoconnect logic + */ + // eslint-disable-next-line @typescript-eslint/require-await + public async disconnect (): Promise { + // FIXME This should see if the extension's state's provider can disconnect + console.error('PostMessageProvider.disconnect() is not implemented.'); + } + + /** + * @summary `true` when this provider supports subscriptions + */ + public get hasSubscriptions (): boolean { + // FIXME This should see if the extension's state's provider has subscriptions + return !!true; + } + + /** + * @summary Whether the node is connected or not. + * @return {boolean} true if connected + */ + public get isConnected (): boolean { + return this.#isConnected; + } + + public listProviders (): Promise { + return sendRequest('pub(rpc.listProviders)', undefined); + } + + /** + * @summary Listens on events after having subscribed using the [[subscribe]] function. + * @param {ProviderInterfaceEmitted} type Event + * @param {ProviderInterfaceEmitCb} sub Callback + * @return unsubscribe function + */ + public on (type: ProviderInterfaceEmitted, sub: ProviderInterfaceEmitCb): () => void { + this.#eventemitter.on(type, sub); + + return (): void => { + this.#eventemitter.removeListener(type, sub); + }; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public async send (method: string, params: unknown[], _?: boolean, subscription?: SubscriptionHandler): Promise { + if (subscription) { + const { callback, type } = subscription; + + const id = await sendRequest('pub(rpc.subscribe)', { method, params, type }, (res): void => { + subscription.callback(null, res); + }); + + this.#subscriptions[`${type}::${id}`] = callback; + + return id; + } + + return sendRequest('pub(rpc.send)', { method, params }); + } + + /** + * @summary Spawn a provider on the extension background. + */ + public async startProvider (key: string): Promise { + // Disconnect from the previous provider + this.#isConnected = false; + this.#eventemitter.emit('disconnected'); + + const meta = await sendRequest('pub(rpc.startProvider)', key); + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + sendRequest('pub(rpc.subscribeConnected)', null, (connected) => { + this.#isConnected = connected; + + if (connected) { + this.#eventemitter.emit('connected'); + } else { + this.#eventemitter.emit('disconnected'); + } + + return true; + }); + + return meta; + } + + public subscribe (type: string, method: string, params: unknown[], callback: AnyFunction): Promise { + return this.send(method, params, false, { callback, type }) as Promise; + } + + /** + * @summary Allows unsubscribing to subscriptions made with [[subscribe]]. + */ + public async unsubscribe (type: string, method: string, id: number): Promise { + const subscription = `${type}::${id}`; + + // FIXME This now could happen with re-subscriptions. The issue is that with a re-sub + // the assigned id now does not match what the API user originally received. It has + // a slight complication in solving - since we cannot rely on the send id, but rather + // need to find the actual subscription id to map it + if (isUndefined(this.#subscriptions[subscription])) { + l.debug((): string => `Unable to find active subscription=${subscription}`); + + return false; + } + + delete this.#subscriptions[subscription]; + + return this.send(method, [id]) as Promise; + } +} diff --git a/packages/extension-base/src/page/Signer.ts b/packages/extension-base/src/page/Signer.ts new file mode 100644 index 0000000..a40a7c1 --- /dev/null +++ b/packages/extension-base/src/page/Signer.ts @@ -0,0 +1,45 @@ +// Copyright 2019-2025 @pezkuwi/extension-base authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { Signer as SignerInterface, SignerResult } from '@pezkuwi/api/types'; +import type { SignerPayloadJSON, SignerPayloadRaw } from '@pezkuwi/types/types'; +import type { SendRequest } from './types.js'; + +// External to class, this.# is not private enough (yet) +let sendRequest: SendRequest; +let nextId = 0; + +export default class Signer implements SignerInterface { + constructor (_sendRequest: SendRequest) { + sendRequest = _sendRequest; + } + + public async signPayload (payload: SignerPayloadJSON): Promise { + const id = ++nextId; + const result = await sendRequest('pub(extrinsic.sign)', payload); + + // we add an internal id (number) - should have a mapping from the + // extension id (string) -> internal id (number) if we wish to provide + // updated via the update functionality (noop at this point) + return { + ...result, + id + }; + } + + public async signRaw (payload: SignerPayloadRaw): Promise { + const id = ++nextId; + const result = await sendRequest('pub(bytes.sign)', payload); + + return { + ...result, + id + }; + } + + // NOTE We don't listen to updates at all, if we do we can interpret the + // result as provided by the API here + // public update (id: number, status: Hash | SubmittableResult): void { + // // ignore + // } +} diff --git a/packages/extension-base/src/page/index.ts b/packages/extension-base/src/page/index.ts new file mode 100644 index 0000000..0c2430d --- /dev/null +++ b/packages/extension-base/src/page/index.ts @@ -0,0 +1,89 @@ +// Copyright 2019-2025 @pezkuwi/extension-base authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/* eslint-disable no-redeclare */ + +import type { MessageTypes, MessageTypesWithNoSubscriptions, MessageTypesWithNullRequest, MessageTypesWithSubscriptions, RequestTypes, ResponseTypes, SubscriptionMessageTypes, TransportRequestMessage, TransportResponseMessage } from '../background/types.js'; + +import { MESSAGE_ORIGIN_PAGE } from '../defaults.js'; +import { getId } from '../utils/getId.js'; +import Injected from './Injected.js'; + +// when sending a message from the injector to the extension, we +// - create an event - this we send to the loader +// - the loader takes this event and uses port.postMessage to background +// - on response, the loader creates a response event +// - this injector, listens on the events, maps it to the original +// - resolves/rejects the promise with the result (or sub data) + +export interface Handler { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + resolve: (data?: any) => void; + reject: (error: Error) => void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + subscriber?: (data: any) => void; +} + +export type Handlers = Record; + +const handlers: Handlers = {}; + +// a generic message sender that creates an event, returning a promise that will +// resolve once the event is resolved (by the response listener just below this) +export function sendMessage(message: TMessageType): Promise; +export function sendMessage(message: TMessageType, request: RequestTypes[TMessageType]): Promise; +export function sendMessage(message: TMessageType, request: RequestTypes[TMessageType], subscriber: (data: SubscriptionMessageTypes[TMessageType]) => void): Promise; + +export function sendMessage (message: TMessageType, request?: RequestTypes[TMessageType], subscriber?: (data: unknown) => void): Promise { + return new Promise((resolve, reject): void => { + const id = getId(); + + handlers[id] = { reject, resolve, subscriber }; + + const transportRequestMessage: TransportRequestMessage = { + id, + message, + origin: MESSAGE_ORIGIN_PAGE, + request: request || null as RequestTypes[TMessageType] + }; + + window.postMessage(transportRequestMessage, '*'); + }); +} + +// the enable function, called by the dapp to allow access +export async function enable (origin: string): Promise { + await sendMessage('pub(authorize.tab)', { origin }); + + return new Injected(sendMessage); +} + +// redirect users if this page is considered as phishing, otherwise return false +export async function redirectIfPhishing (): Promise { + const res = await sendMessage('pub(phishing.redirectIfDenied)'); + + return res; +} + +export function handleResponse (data: TransportResponseMessage & { subscription?: string }): void { + const handler = handlers[data.id]; + + if (!handler) { + console.error(`Unknown response: ${JSON.stringify(data)}`); + + return; + } + + if (!handler.subscriber) { + delete handlers[data.id]; + } + + if (data.subscription) { + // eslint-disable-next-line @typescript-eslint/ban-types + (handler.subscriber as Function)(data.subscription); + } else if (data.error) { + handler.reject(new Error(data.error)); + } else { + handler.resolve(data.response); + } +} diff --git a/packages/extension-base/src/page/types.ts b/packages/extension-base/src/page/types.ts new file mode 100644 index 0000000..4b468ef --- /dev/null +++ b/packages/extension-base/src/page/types.ts @@ -0,0 +1,10 @@ +// Copyright 2019-2025 @pezkuwi/extension-base authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { MessageTypesWithNoSubscriptions, MessageTypesWithNullRequest, MessageTypesWithSubscriptions, RequestTypes, ResponseTypes, SubscriptionMessageTypes } from '../background/types.js'; + +export interface SendRequest { + (message: TMessageType): Promise; + (message: TMessageType, request: RequestTypes[TMessageType]): Promise; + (message: TMessageType, request: RequestTypes[TMessageType], subscriber: (data: SubscriptionMessageTypes[TMessageType]) => void): Promise; +} diff --git a/packages/extension-base/src/stores/Accounts.ts b/packages/extension-base/src/stores/Accounts.ts new file mode 100644 index 0000000..256fc11 --- /dev/null +++ b/packages/extension-base/src/stores/Accounts.ts @@ -0,0 +1,28 @@ +// Copyright 2019-2025 @pezkuwi/extension-base authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { KeyringJson, KeyringStore } from '@pezkuwi/ui-keyring/types'; + +import { EXTENSION_PREFIX } from '../defaults.js'; +import BaseStore from './Base.js'; + +export default class AccountsStore extends BaseStore implements KeyringStore { + constructor () { + super( + EXTENSION_PREFIX && EXTENSION_PREFIX !== 'polkadot{.js}' + ? `${EXTENSION_PREFIX}accounts` + : null + ); + } + + public override async set (key: string, value: KeyringJson, update?: () => void): Promise { + // shortcut, don't save testing accounts in extension storage + if (key.startsWith('account:') && value.meta && value.meta.isTesting) { + update && update(); + + return; + } + + await super.set(key, value, update); + } +} diff --git a/packages/extension-base/src/stores/Base.ts b/packages/extension-base/src/stores/Base.ts new file mode 100644 index 0000000..295f479 --- /dev/null +++ b/packages/extension-base/src/stores/Base.ts @@ -0,0 +1,93 @@ +// Copyright 2019-2025 @pezkuwi/extension-base authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/* global chrome */ + +type StoreValue = Record; + +const lastError = (type: string): void => { + const error = chrome.runtime.lastError; + + if (error) { + console.error(`BaseStore.${type}:: runtime.lastError:`, error); + } +}; + +export default abstract class BaseStore { + #prefix: string; + + constructor (prefix: string | null) { + this.#prefix = prefix ? `${prefix}:` : ''; + } + + public async all (update: (key: string, value: T) => void): Promise { + await this.allMap(async (map): Promise => { + const entries = Object.entries(map); + + for (const [key, value] of entries) { + // eslint-disable-next-line @typescript-eslint/await-thenable + await update(key, value); + } + }); + } + + public async allMap (update: (value: Record) => Promise): Promise { + await chrome.storage.local.get(null).then(async (result: StoreValue) => { + lastError('all'); + + const entries = Object.entries(result); + const map: Record = {}; + + for (let i = 0, count = entries.length; i < count; i++) { + const [key, value] = entries[i]; + + if (key.startsWith(this.#prefix)) { + map[key.replace(this.#prefix, '')] = value as T; + } + } + + await update(map); + }).catch(({ message }: Error) => { + console.error(`BaseStore error within allMap: ${message}`); + }); + } + + public async get (key: string, update: (value: T) => void): Promise { + const prefixedKey = `${this.#prefix}${key}`; + + await chrome.storage.local.get([prefixedKey]).then(async (result: StoreValue) => { + lastError('get'); + + // eslint-disable-next-line @typescript-eslint/await-thenable + await update(result[prefixedKey] as T); + }).catch(({ message }: Error) => { + console.error(`BaseStore error within get: ${message}`); + }); + } + + public async remove (key: string, update?: () => void): Promise { + const prefixedKey = `${this.#prefix}${key}`; + + await chrome.storage.local.remove(prefixedKey).then(async () => { + lastError('remove'); + + // eslint-disable-next-line @typescript-eslint/await-thenable + update && await update(); + }).catch(({ message }: Error) => { + console.error(`BaseStore error within remove: ${message}`); + }); + } + + public async set (key: string, value: T, update?: () => void): Promise { + const prefixedKey = `${this.#prefix}${key}`; + + await chrome.storage.local.set({ [prefixedKey]: value }).then(async () => { + lastError('set'); + + // eslint-disable-next-line @typescript-eslint/await-thenable + update && await update(); + }).catch(({ message }: Error) => { + console.error(`BaseStore error within set: ${message}`); + }); + } +} diff --git a/packages/extension-base/src/stores/Metadata.ts b/packages/extension-base/src/stores/Metadata.ts new file mode 100644 index 0000000..492a927 --- /dev/null +++ b/packages/extension-base/src/stores/Metadata.ts @@ -0,0 +1,17 @@ +// Copyright 2019-2025 @pezkuwi/extension-base authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { MetadataDef } from '@pezkuwi/extension-inject/types'; + +import { EXTENSION_PREFIX } from '../defaults.js'; +import BaseStore from './Base.js'; + +export default class MetadataStore extends BaseStore { + constructor () { + super( + EXTENSION_PREFIX && EXTENSION_PREFIX !== 'polkadot{.js}' + ? `${EXTENSION_PREFIX}metadata` + : 'metadata' + ); + } +} diff --git a/packages/extension-base/src/stores/index.ts b/packages/extension-base/src/stores/index.ts new file mode 100644 index 0000000..5ff4302 --- /dev/null +++ b/packages/extension-base/src/stores/index.ts @@ -0,0 +1,5 @@ +// Copyright 2019-2025 @pezkuwi/extension-base authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +export { default as AccountsStore } from './Accounts.js'; +export { default as MetadataStore } from './Metadata.js'; diff --git a/packages/extension-base/src/types.ts b/packages/extension-base/src/types.ts new file mode 100644 index 0000000..2103a28 --- /dev/null +++ b/packages/extension-base/src/types.ts @@ -0,0 +1,12 @@ +// Copyright 2019-2025 @pezkuwi/extension-base authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +export interface Message extends MessageEvent { + data: { + error?: string; + id: string; + origin: string; + response?: string; + subscription?: string; + } +} diff --git a/packages/extension-base/src/utils/canDerive.ts b/packages/extension-base/src/utils/canDerive.ts new file mode 100644 index 0000000..bad60d3 --- /dev/null +++ b/packages/extension-base/src/utils/canDerive.ts @@ -0,0 +1,8 @@ +// Copyright 2019-2025 @pezkuwi/extension-base authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { KeypairType } from '@pezkuwi/util-crypto/types'; + +export function canDerive (type?: KeypairType): boolean { + return !!type && ['ed25519', 'sr25519', 'ecdsa', 'ethereum'].includes(type); +} diff --git a/packages/extension-base/src/utils/getId.ts b/packages/extension-base/src/utils/getId.ts new file mode 100644 index 0000000..8c3e407 --- /dev/null +++ b/packages/extension-base/src/utils/getId.ts @@ -0,0 +1,10 @@ +// Copyright 2019-2025 @pezkuwi/extension-base authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import { EXTENSION_PREFIX } from '../defaults.js'; + +let counter = 0; + +export function getId (): string { + return `${EXTENSION_PREFIX}.${Date.now()}.${++counter}`; +} diff --git a/packages/extension-base/src/utils/index.ts b/packages/extension-base/src/utils/index.ts new file mode 100644 index 0000000..d1cab02 --- /dev/null +++ b/packages/extension-base/src/utils/index.ts @@ -0,0 +1,4 @@ +// Copyright 2019-2025 @pezkuwi/extension-base authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +export { canDerive } from './canDerive.js'; diff --git a/packages/extension-base/src/utils/portUtils.ts b/packages/extension-base/src/utils/portUtils.ts new file mode 100644 index 0000000..5826a1b --- /dev/null +++ b/packages/extension-base/src/utils/portUtils.ts @@ -0,0 +1,65 @@ +// Copyright 2019-2025 @pezkuwi/extension-base authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { Message } from '@pezkuwi/extension-base/types'; + +import { chrome } from '@pezkuwi/extension-inject/chrome'; + +export function setupPort (portName: string, onMessageHandler: (data: Message['data']) => void, onDisconnectHandler: () => void): chrome.runtime.Port { + const port = chrome.runtime.connect({ name: portName }); + + port.onMessage.addListener(onMessageHandler); + + port.onDisconnect.addListener(() => { + console.log(`Disconnected from ${portName}`); + onDisconnectHandler(); + }); + + return port; +} + +export async function wakeUpServiceWorker (): Promise<{ status: string }> { + return new Promise((resolve, reject) => { + chrome.runtime.sendMessage({ type: 'wakeup' }, (response: { status: string }) => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message)); + } else { + resolve(response); + } + }); + }); +} + +// This object is required to allow jest.spyOn to be used to create a mock Implementation for testing +export const wakeUpServiceWorkerWrapper = { wakeUpServiceWorker }; + +export async function ensurePortConnection ( + portRef: chrome.runtime.Port | undefined, + portConfig: { + portName: string, + onPortMessageHandler: (data: Message['data']) => void, + onPortDisconnectHandler: () => void + } +): Promise { + const maxAttempts = 5; + const delayMs = 1000; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + try { + const response = await wakeUpServiceWorkerWrapper.wakeUpServiceWorker(); + + if (response?.status === 'awake') { + if (!portRef) { + return setupPort(portConfig.portName, portConfig.onPortMessageHandler, portConfig.onPortDisconnectHandler); + } + + return portRef; + } + } catch (error) { + console.error(`Attempt ${attempt + 1} failed: ${(error as Error).message}`); + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + } + + throw new Error('Failed to wake up the service worker and setup the port after multiple attempts'); +} diff --git a/packages/extension-base/tsconfig.build.json b/packages/extension-base/tsconfig.build.json new file mode 100644 index 0000000..ae8ab4e --- /dev/null +++ b/packages/extension-base/tsconfig.build.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": "..", + "outDir": "./build", + "rootDir": "./src" + }, + "exclude": [ + "**/*.spec.ts" + ], + "references": [ + { "path": "../extension-chains/tsconfig.build.json" }, + { "path": "../extension-dapp/tsconfig.build.json" }, + { "path": "../extension-inject/tsconfig.build.json" } + ] +} diff --git a/packages/extension-base/tsconfig.spec.json b/packages/extension-base/tsconfig.spec.json new file mode 100644 index 0000000..0bf252b --- /dev/null +++ b/packages/extension-base/tsconfig.spec.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": "..", + "outDir": "./build", + "rootDir": "./src", + "emitDeclarationOnly": false, + "noEmit": true + }, + "include": [ + "**/*.spec.ts" + ], + "references": [ + { "path": "../extension-base/tsconfig.build.json" }, + { "path": "../extension-inject/tsconfig.build.json" }, + { "path": "../extension-mocks/tsconfig.build.json" } + ] +} diff --git a/packages/extension-chains/README.md b/packages/extension-chains/README.md new file mode 100644 index 0000000..5f45688 --- /dev/null +++ b/packages/extension-chains/README.md @@ -0,0 +1,3 @@ +# @polkadot/extension-chains + +Definitions for chains that are supported by this extension. It contains the bare definitions as well as a stripped-down (call-only) metadata format. diff --git a/packages/extension-chains/package.json b/packages/extension-chains/package.json new file mode 100644 index 0000000..79c4da8 --- /dev/null +++ b/packages/extension-chains/package.json @@ -0,0 +1,34 @@ +{ + "author": "Jaco Greeff ", + "bugs": "https://github.com/pezkuwichain/pezkuwi-extension/issues", + "description": "Definitions for all known chains as exposed by the extension.", + "engines": { + "node": ">=18" + }, + "homepage": "https://github.com/pezkuwichain/pezkuwi-extension/tree/master/packages/extension-chains#readme", + "license": "Apache-2.0", + "name": "@pezkuwi/extension-chains", + "repository": { + "directory": "packages/extension-chains", + "type": "git", + "url": "https://github.com/pezkuwichain/pezkuwi-extension.git" + }, + "sideEffects": [ + "./packageDetect.js", + "./packageDetect.cjs" + ], + "type": "module", + "version": "0.62.6", + "main": "index.js", + "dependencies": { + "@pezkuwi/extension-inject": "0.62.6", + "@pezkuwi/networks": "^14.0.5", + "@pezkuwi/util": "^14.0.5", + "@pezkuwi/util-crypto": "^14.0.5", + "tslib": "^2.8.1" + }, + "peerDependencies": { + "@pezkuwi/api": "*", + "@pezkuwi/types": "*" + } +} diff --git a/packages/extension-chains/src/bundle.ts b/packages/extension-chains/src/bundle.ts new file mode 100644 index 0000000..f7199f5 --- /dev/null +++ b/packages/extension-chains/src/bundle.ts @@ -0,0 +1,87 @@ +// Copyright 2019-2025 @pezkuwi/extension-chains authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { MetadataDef } from '@pezkuwi/extension-inject/types'; +import type { ChainProperties } from '@pezkuwi/types/interfaces'; +import type { Chain } from './types.js'; + +import { Metadata, TypeRegistry } from '@pezkuwi/types'; +import { base64Decode } from '@pezkuwi/util-crypto'; + +export { packageInfo } from './packageInfo.js'; + +// imports chain details, generally metadata. For the generation of these, +// inside the api, run `yarn chain:info --ws ` + +const definitions = new Map( + // [kusama].map((def) => [def.genesisHash, def]) +); + +const expanded = new Map(); + +export function metadataExpand (definition: MetadataDef, isPartial = false): Chain { + const cached = expanded.get(definition.genesisHash); + + if (cached && cached.specVersion === definition.specVersion) { + return cached; + } + + const { chain, genesisHash, icon, metaCalls, specVersion, ss58Format, tokenDecimals, tokenSymbol, types, userExtensions } = definition; + const registry = new TypeRegistry(); + + if (!isPartial) { + registry.register(types); + } + + registry.setChainProperties(registry.createType('ChainProperties', { + ss58Format, + tokenDecimals, + tokenSymbol + }) as unknown as ChainProperties); + + const hasMetadata = !!metaCalls && !isPartial; + + if (hasMetadata) { + registry.setMetadata(new Metadata(registry, base64Decode(metaCalls)), undefined, userExtensions); + } + + const isUnknown = genesisHash === '0x'; + + const result = { + definition, + genesisHash: isUnknown + ? undefined + : genesisHash, + hasMetadata, + icon: icon || 'substrate', + isUnknown, + name: chain, + registry, + specVersion, + ss58Format, + tokenDecimals, + tokenSymbol + }; + + if (result.genesisHash && !isPartial) { + expanded.set(result.genesisHash, result); + } + + return result; +} + +export function findChain (definitions: MetadataDef[], genesisHash?: string | null): Chain | null { + const def = definitions.find((def) => def.genesisHash === genesisHash); + + return def + ? metadataExpand(def) + : null; +} + +export function addMetadata (def: MetadataDef): void { + definitions.set(def.genesisHash, def); +} + +export function knownMetadata (): MetadataDef[] { + return [...definitions.values()]; +} diff --git a/packages/extension-chains/src/index.ts b/packages/extension-chains/src/index.ts new file mode 100644 index 0000000..d4332d3 --- /dev/null +++ b/packages/extension-chains/src/index.ts @@ -0,0 +1,7 @@ +// Copyright 2019-2025 @pezkuwi/extension-chains authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +// Since we inject into pages, we skip this +// import './packageDetect.js'; + +export * from './bundle.js'; diff --git a/packages/extension-chains/src/packageDetect.ts b/packages/extension-chains/src/packageDetect.ts new file mode 100644 index 0000000..d65f2c7 --- /dev/null +++ b/packages/extension-chains/src/packageDetect.ts @@ -0,0 +1,12 @@ +// Copyright 2017-2025 @pezkuwi/extension-chains authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +// Do not edit, auto-generated by @pezkuwi/dev +// (packageInfo imports will be kept as-is, user-editable) + +import { packageInfo as injectInfo } from '@pezkuwi/extension-inject/packageInfo'; +import { detectPackage } from '@pezkuwi/util'; + +import { packageInfo } from './packageInfo.js'; + +detectPackage(packageInfo, null, [injectInfo]); diff --git a/packages/extension-chains/src/packageInfo.ts b/packages/extension-chains/src/packageInfo.ts new file mode 100644 index 0000000..a0d9ffa --- /dev/null +++ b/packages/extension-chains/src/packageInfo.ts @@ -0,0 +1,6 @@ +// Copyright 2017-2025 @pezkuwi/extension-chains authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +// Do not edit, auto-generated by @pezkuwi/dev + +export const packageInfo = { name: '@pezkuwi/extension-chains', path: 'auto', type: 'auto', version: '0.62.6' }; diff --git a/packages/extension-chains/src/types.ts b/packages/extension-chains/src/types.ts new file mode 100644 index 0000000..ea37604 --- /dev/null +++ b/packages/extension-chains/src/types.ts @@ -0,0 +1,20 @@ +// Copyright 2019-2025 @pezkuwi/extension-chains authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { MetadataDef } from '@pezkuwi/extension-inject/types'; +import type { Registry } from '@pezkuwi/types/types'; + +export interface Chain { + definition: MetadataDef; + genesisHash?: string; + hasMetadata: boolean; + icon: string; + isEthereum?: boolean; + isUnknown?: boolean; + name: string; + registry: Registry; + specVersion: number; + ss58Format: number; + tokenDecimals: number; + tokenSymbol: string; +} diff --git a/packages/extension-chains/tsconfig.build.json b/packages/extension-chains/tsconfig.build.json new file mode 100644 index 0000000..b27ae67 --- /dev/null +++ b/packages/extension-chains/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": "..", + "outDir": "./build", + "rootDir": "./src" + }, + "references": [ + { "path": "../extension-inject/tsconfig.build.json" } + ] +} diff --git a/packages/extension-compat-metamask/README.md b/packages/extension-compat-metamask/README.md new file mode 100644 index 0000000..f5f91f2 --- /dev/null +++ b/packages/extension-compat-metamask/README.md @@ -0,0 +1,3 @@ +# @polkadot/extension-metamask-compat + +An optional metamask-compatible layer diff --git a/packages/extension-compat-metamask/package.json b/packages/extension-compat-metamask/package.json new file mode 100644 index 0000000..0bda529 --- /dev/null +++ b/packages/extension-compat-metamask/package.json @@ -0,0 +1,35 @@ +{ + "author": "Jaco Greeff ", + "bugs": "https://github.com/pezkuwichain/pezkuwi-extension/issues", + "description": "Metamask compatibility layer", + "engines": { + "node": ">=18" + }, + "homepage": "https://github.com/pezkuwichain/pezkuwi-extension/tree/master/packages/extension-compat-metamask#readme", + "license": "Apache-2.0", + "name": "@pezkuwi/extension-compat-metamask", + "repository": { + "directory": "packages/extension-compat-metamask", + "type": "git", + "url": "https://github.com/pezkuwichain/pezkuwi-extension.git" + }, + "sideEffects": [ + "./packageDetect.js", + "./packageDetect.cjs" + ], + "type": "module", + "version": "0.62.6", + "main": "index.js", + "dependencies": { + "@metamask/detect-provider": "^2.0.0", + "@pezkuwi/extension-inject": "0.62.6", + "@pezkuwi/types": "^16.5.3", + "@pezkuwi/util": "^14.0.5", + "tslib": "^2.8.1", + "web3": "^4.7.0" + }, + "peerDependencies": { + "@pezkuwi/api": "*", + "@pezkuwi/util": "*" + } +} diff --git a/packages/extension-compat-metamask/src/bundle.ts b/packages/extension-compat-metamask/src/bundle.ts new file mode 100644 index 0000000..fe67913 --- /dev/null +++ b/packages/extension-compat-metamask/src/bundle.ts @@ -0,0 +1,102 @@ +// Copyright 2019-2025 @pezkuwi/extension-compat-metamask authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { Injected, InjectedAccount, InjectedWindow } from '@pezkuwi/extension-inject/types'; +import type { SignerPayloadRaw, SignerResult } from '@pezkuwi/types/types'; +import type { HexString } from '@pezkuwi/util/types'; + +import detectEthereumProvider from '@metamask/detect-provider'; +import Web3 from 'web3'; + +import { assert } from '@pezkuwi/util'; + +export { packageInfo } from './packageInfo.js'; + +interface RequestArguments { + method: string; + params?: unknown[]; +} + +interface EthRpcSubscription { + unsubscribe: () => void +} + +interface EthereumProvider { + request: (args: RequestArguments) => Promise; + isMetaMask: boolean; + on: (name: string, cb: (value: unknown) => void) => EthRpcSubscription; +} + +interface Web3Window extends InjectedWindow { + // this is injected by metaMask + ethereum: unknown; +} + +function isMetaMaskProvider (prov: unknown): EthereumProvider { + assert(prov && (prov as EthereumProvider).isMetaMask, 'Injected provider is not MetaMask'); + + return (prov as EthereumProvider); +} + +// transform the Web3 accounts into a simple address/name array +function transformAccounts (accounts: string[]): InjectedAccount[] { + return accounts.map((address, i) => ({ + address, + name: `MetaMask Address #${i}`, + type: 'ethereum' + })); +} + +// add a compat interface of metaMaskSource to window.injectedWeb3 +function injectMetaMaskWeb3 (win: Web3Window): void { + // decorate the compat interface + win.injectedWeb3['Web3Source'] = { + enable: async (): Promise => { + const providerRaw = await detectEthereumProvider({ mustBeMetaMask: true }); + const provider = isMetaMaskProvider(providerRaw); + + await provider.request({ method: 'eth_requestAccounts' }); + + return { + accounts: { + get: async (): Promise => { + const response = (await provider.request({ method: 'eth_requestAccounts' })) as string[]; + + return transformAccounts(response); + }, + subscribe: (cb: (accounts: InjectedAccount[]) => void): (() => void) => { + const sub = provider.on('accountsChanged', (accounts): void => { + cb(transformAccounts(accounts as string[])); + }); + // TODO: add onchainchanged + + return (): void => { + sub.unsubscribe(); + }; + } + }, + signer: { + signRaw: async (raw: SignerPayloadRaw): Promise => { + const signature = (await provider.request({ method: 'eth_sign', params: [raw.address, Web3.utils.sha3(raw.data)] })) as HexString; + + return { id: 0, signature }; + } + } + }; + }, + version: '0' // TODO: win.ethereum.version + }; +} + +export default function initMetaMask (): Promise { + return new Promise((resolve): void => { + const win = window as Window & Web3Window; + + if (win.ethereum) { + injectMetaMaskWeb3(win); + resolve(true); + } else { + resolve(false); + } + }); +} diff --git a/packages/extension-compat-metamask/src/index.ts b/packages/extension-compat-metamask/src/index.ts new file mode 100644 index 0000000..027dd03 --- /dev/null +++ b/packages/extension-compat-metamask/src/index.ts @@ -0,0 +1,7 @@ +// Copyright 2019-2025 @pezkuwi/extension-compat-metamask authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +// Since we inject into pages, we skip this +// import './detectPackage'; + +export * from './bundle.js'; diff --git a/packages/extension-compat-metamask/src/packageDetect.ts b/packages/extension-compat-metamask/src/packageDetect.ts new file mode 100644 index 0000000..c3b0ba5 --- /dev/null +++ b/packages/extension-compat-metamask/src/packageDetect.ts @@ -0,0 +1,12 @@ +// Copyright 2017-2025 @pezkuwi/extension-compat-metamask authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +// Do not edit, auto-generated by @pezkuwi/dev +// (packageInfo imports will be kept as-is, user-editable) + +import { packageInfo as injectInfo } from '@pezkuwi/extension-inject/packageInfo'; +import { detectPackage } from '@pezkuwi/util'; + +import { packageInfo } from './packageInfo.js'; + +detectPackage(packageInfo, null, [injectInfo]); diff --git a/packages/extension-compat-metamask/src/packageInfo.ts b/packages/extension-compat-metamask/src/packageInfo.ts new file mode 100644 index 0000000..ee7c3d4 --- /dev/null +++ b/packages/extension-compat-metamask/src/packageInfo.ts @@ -0,0 +1,6 @@ +// Copyright 2017-2025 @pezkuwi/extension-compat-metamask authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +// Do not edit, auto-generated by @pezkuwi/dev + +export const packageInfo = { name: '@pezkuwi/extension-compat-metamask', path: 'auto', type: 'auto', version: '0.62.6' }; diff --git a/packages/extension-compat-metamask/tsconfig.build.json b/packages/extension-compat-metamask/tsconfig.build.json new file mode 100644 index 0000000..b27ae67 --- /dev/null +++ b/packages/extension-compat-metamask/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": "..", + "outDir": "./build", + "rootDir": "./src" + }, + "references": [ + { "path": "../extension-inject/tsconfig.build.json" } + ] +} diff --git a/packages/extension-dapp/README.md b/packages/extension-dapp/README.md new file mode 100644 index 0000000..e3b14b5 --- /dev/null +++ b/packages/extension-dapp/README.md @@ -0,0 +1,3 @@ +# @polkadot/extension-dapp + +Documentation available [in the pezkuwi doc](https://js.pezkuwichain.app/docs/extension). diff --git a/packages/extension-dapp/package.json b/packages/extension-dapp/package.json new file mode 100644 index 0000000..e56c4db --- /dev/null +++ b/packages/extension-dapp/package.json @@ -0,0 +1,37 @@ +{ + "author": "Jaco Greeff ", + "bugs": "https://github.com/pezkuwichain/pezkuwi-extension/issues", + "description": "Provides an interfaces around the injected globals for ease of access by dapp developers.", + "engines": { + "node": ">=18" + }, + "homepage": "https://github.com/pezkuwichain/pezkuwi-extension/tree/master/packages/extension-dapp#readme", + "license": "Apache-2.0", + "name": "@pezkuwi/extension-dapp", + "repository": { + "directory": "packages/extension-dapp", + "type": "git", + "url": "https://github.com/pezkuwichain/pezkuwi-extension.git" + }, + "sideEffects": [ + "./packageDetect.js", + "./packageDetect.cjs" + ], + "type": "module", + "version": "0.62.6", + "main": "index.js", + "dependencies": { + "@pezkuwi/extension-inject": "0.62.6", + "@pezkuwi/util": "^14.0.5", + "@pezkuwi/util-crypto": "^14.0.5", + "tslib": "^2.8.1" + }, + "devDependencies": { + "@pezkuwi/dev-test": "^0.84.3" + }, + "peerDependencies": { + "@pezkuwi/api": "*", + "@pezkuwi/util": "*", + "@pezkuwi/util-crypto": "*" + } +} diff --git a/packages/extension-dapp/src/bundle.ts b/packages/extension-dapp/src/bundle.ts new file mode 100644 index 0000000..5487ad4 --- /dev/null +++ b/packages/extension-dapp/src/bundle.ts @@ -0,0 +1,319 @@ +// Copyright 2019-2025 @pezkuwi/extension-dapp authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { InjectedAccount, InjectedAccountWithMeta, InjectedExtension, InjectedProviderWithMeta, InjectedWindow, ProviderList, Unsubcall, Web3AccountsOptions } from '@pezkuwi/extension-inject/types'; + +import { isPromise, objectSpread, u8aEq } from '@pezkuwi/util'; +import { decodeAddress, encodeAddress } from '@pezkuwi/util-crypto'; + +import { documentReadyPromise } from './util.js'; + +// expose utility functions +export { packageInfo } from './packageInfo.js'; +export { unwrapBytes, wrapBytes } from './wrapBytes.js'; + +// just a helper (otherwise we cast all-over, so shorter and more readable) +const win = window as Window & InjectedWindow; + +// don't clobber the existing object, but ensure non-undefined +win.injectedWeb3 = win.injectedWeb3 || {}; + +// have we found a properly constructed window.injectedWeb3 +let isWeb3Injected = web3IsInjected(); + +// we keep the last promise created around (for queries) +let web3EnablePromise: Promise | null = null; + +export { isWeb3Injected, web3EnablePromise }; + +/** @internal true when anything has been injected and is available */ +function web3IsInjected (): boolean { + return Object + .values(win.injectedWeb3) + .filter(({ connect, enable }) => !!(connect || enable)) + .length !== 0; +} + +/** @internal throw a consistent error when not extensions have not been enabled */ +function throwError (method: string): never { + throw new Error(`${method}: web3Enable(originName) needs to be called before ${method}`); +} + +/** @internal map from Array to Array */ +function mapAccounts (source: string, list: InjectedAccount[], ss58Format?: number): InjectedAccountWithMeta[] { + return list.map(({ address, genesisHash, name, type }): InjectedAccountWithMeta => ({ + address: address.length === 42 + ? address + : encodeAddress(decodeAddress(address), ss58Format), + meta: { genesisHash, name, source }, + type + })); +} + +/** @internal filter accounts based on genesisHash and type of account */ +function filterAccounts (list: InjectedAccount[], genesisHash?: string | null, type?: string[]): InjectedAccount[] { + return list.filter((a) => + (!a.type || !type || type.includes(a.type)) && + (!a.genesisHash || !genesisHash || a.genesisHash === genesisHash) + ); +} + +/** @internal retrieves all the extensions available on the window */ +function getWindowExtensions (originName: string): Promise { + return Promise + .all( + Object + .entries(win.injectedWeb3) + .map(([nameOrHash, { connect, enable, version }]): Promise<(InjectedExtension | void)> => + Promise + .resolve() + .then(() => + connect + // new style, returning all info + ? connect(originName) + : enable + // previous interface, leakages on name/version + ? enable(originName).then((e) => + objectSpread({ name: nameOrHash, version: version || 'unknown' }, e) + ) + : Promise.reject(new Error('No connect(..) or enable(...) hook found')) + ) + .catch(({ message }: Error): void => { + console.error(`Error initializing ${nameOrHash}: ${message}`); + }) + ) + ) + .then((exts) => exts.filter((e): e is InjectedExtension => !!e)); +} + +/** @internal Ensure the enable promise is resolved and filter by extensions */ +async function filterEnable (caller: 'web3Accounts' | 'web3AccountsSubscribe', extensions?: string[]): Promise { + if (!web3EnablePromise) { + return throwError(caller); + } + + const sources = await web3EnablePromise; + + return sources.filter(({ name }) => + !extensions || + extensions.includes(name) + ); +} + +/** + * @summary Enables all the providers found on the injected window interface + * @description + * Enables all injected extensions that has been found on the page. This + * should be called before making use of any other web3* functions. + */ +export function web3Enable (originName: string, compatInits: (() => Promise)[] = []): Promise { + if (!originName) { + throw new Error('You must pass a name for your app to the web3Enable function'); + } + + const initCompat = compatInits.length + ? Promise.all(compatInits.map((c) => c().catch(() => false))) + : Promise.resolve([true]); + + web3EnablePromise = documentReadyPromise( + (): Promise => + initCompat.then(() => + getWindowExtensions(originName) + .then((values): InjectedExtension[] => + values.map((e): InjectedExtension => { + // if we don't have an accounts subscriber, add a single-shot version + if (!e.accounts.subscribe) { + e.accounts.subscribe = (cb: (accounts: InjectedAccount[]) => void | Promise): Unsubcall => { + e.accounts + .get() + .then(cb) + .catch(console.error); + + return (): void => { + // no ubsubscribe needed, this is a single-shot + }; + }; + } + + return e; + }) + ) + .catch((): InjectedExtension[] => []) + .then((values): InjectedExtension[] => { + const names = values.map(({ name, version }): string => `${name}/${version}`); + + isWeb3Injected = web3IsInjected(); + console.info(`web3Enable: Enabled ${values.length} extension${values.length !== 1 ? 's' : ''}: ${names.join(', ')}`); + + return values; + }) + ) + ); + + return web3EnablePromise; +} + +/** + * @summary Retrieves all the accounts across all providers + * @description + * This returns the full list of account available (across all extensions) to + * the page. Filtering options are available of a per-extension, per type and + * per-genesisHash basis. Optionally the accounts can be encoded with the provided + * ss58Format + */ +export async function web3Accounts ({ accountType, extensions, genesisHash, ss58Format }: Web3AccountsOptions = {}): Promise { + const accounts: InjectedAccountWithMeta[] = []; + const sources = await filterEnable('web3Accounts', extensions); + const retrieved = await Promise.all( + sources.map(async ({ accounts, name: source }): Promise => { + try { + const list = await accounts.get(); + + return mapAccounts(source, filterAccounts(list, genesisHash, accountType), ss58Format); + } catch { + // cannot handle this one + return []; + } + }) + ); + + retrieved.forEach((result): void => { + accounts.push(...result); + }); + + console.info(`web3Accounts: Found ${accounts.length} address${accounts.length !== 1 ? 'es' : ''}`); + + return accounts; +} + +/** + * @summary Subscribes to all the accounts across all providers + * @description + * This is the subscription version of the web3Accounts interface with + * updates as to when new accounts do become available. The list of filtering + * options are the same as for the web3Accounts interface. + */ +export async function web3AccountsSubscribe (cb: (accounts: InjectedAccountWithMeta[]) => void | Promise, { accountType, extensions, genesisHash, ss58Format }: Web3AccountsOptions = {}): Promise { + const sources = await filterEnable('web3AccountsSubscribe', extensions); + const accounts: Record = {}; + + const triggerUpdate = (): void | Promise => + cb( + Object + .entries(accounts) + .reduce((result: InjectedAccountWithMeta[], [source, list]): InjectedAccountWithMeta[] => { + result.push(...mapAccounts(source, filterAccounts(list, genesisHash, accountType), ss58Format)); + + return result; + }, []) + ); + + const unsubs = sources.map(({ accounts: { subscribe }, name: source }): Unsubcall => + subscribe((result): void => { + accounts[source] = result; + + try { + const result = triggerUpdate(); + + if (result && isPromise(result)) { + result.catch(console.error); + } + } catch (error) { + console.error(error); + } + }) + ); + + return (): void => { + unsubs.forEach((unsub): void => { + unsub(); + }); + }; +} + +/** + * @summary Finds a specific provider based on the name + * @description + * This retrieves a specific source (extension) based on the name. In most + * cases it should not be needed to call it directly (e.g. it is used internally + * by calls such as web3FromAddress) but would allow operation on a specific + * known extension. + */ +export async function web3FromSource (source: string): Promise { + if (!web3EnablePromise) { + return throwError('web3FromSource'); + } + + const sources = await web3EnablePromise; + const found = source && sources.find(({ name }) => name === source); + + if (!found) { + throw new Error(`web3FromSource: Unable to find an injected ${source}`); + } + + return found; +} + +/** + * @summary Find a specific provider that provides a specific address + * @description + * Based on an address, return the provider that has makes this address + * available to the page. + */ +export async function web3FromAddress (address: string): Promise { + if (!web3EnablePromise) { + return throwError('web3FromAddress'); + } + + const accounts = await web3Accounts(); + let found: InjectedAccountWithMeta | undefined; + + if (address) { + const accountU8a = decodeAddress(address); + + found = accounts.find((account): boolean => u8aEq(decodeAddress(account.address), accountU8a)); + } + + if (!found) { + throw new Error(`web3FromAddress: Unable to find injected ${address}`); + } + + return web3FromSource(found.meta.source); +} + +/** + * @summary List all providers exposed by one source + * @description + * For extensions that supply RPC providers, this call would return the list + * of RPC providers that any extension may supply. + */ +export async function web3ListRpcProviders (source: string): Promise { + const { provider } = await web3FromSource(source); + + if (!provider) { + console.warn(`Extension ${source} does not expose any provider`); + + return null; + } + + return provider.listProviders(); +} + +/** + * @summary Start an RPC provider provider by a specific source + * @description + * For extensions that supply RPC providers, this call would return an + * enabled provider (initialized with the specific key) from the + * specified extension source. + */ +export async function web3UseRpcProvider (source: string, key: string): Promise { + const { provider } = await web3FromSource(source); + + if (!provider) { + throw new Error(`Extension ${source} does not expose any provider`); + } + + const meta = await provider.startProvider(key); + + return { meta, provider }; +} diff --git a/packages/extension-dapp/src/index.ts b/packages/extension-dapp/src/index.ts new file mode 100644 index 0000000..26fb0bc --- /dev/null +++ b/packages/extension-dapp/src/index.ts @@ -0,0 +1,7 @@ +// Copyright 2019-2025 @pezkuwi/extension-dapp authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +// Since we inject into pages, we skip this +// import './packageDetect.js'; + +export * from './bundle.js'; diff --git a/packages/extension-dapp/src/packageDetect.ts b/packages/extension-dapp/src/packageDetect.ts new file mode 100644 index 0000000..4a37c8a --- /dev/null +++ b/packages/extension-dapp/src/packageDetect.ts @@ -0,0 +1,12 @@ +// Copyright 2017-2025 @pezkuwi/extension-dapp authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +// Do not edit, auto-generated by @pezkuwi/dev +// (packageInfo imports will be kept as-is, user-editable) + +import { packageInfo as injectInfo } from '@pezkuwi/extension-inject/packageInfo'; +import { detectPackage } from '@pezkuwi/util'; + +import { packageInfo } from './packageInfo.js'; + +detectPackage(packageInfo, null, [injectInfo]); diff --git a/packages/extension-dapp/src/packageInfo.ts b/packages/extension-dapp/src/packageInfo.ts new file mode 100644 index 0000000..e91f0f8 --- /dev/null +++ b/packages/extension-dapp/src/packageInfo.ts @@ -0,0 +1,6 @@ +// Copyright 2017-2025 @pezkuwi/extension-dapp authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +// Do not edit, auto-generated by @pezkuwi/dev + +export const packageInfo = { name: '@pezkuwi/extension-dapp', path: 'auto', type: 'auto', version: '0.62.6' }; diff --git a/packages/extension-dapp/src/util.ts b/packages/extension-dapp/src/util.ts new file mode 100644 index 0000000..0f4613b --- /dev/null +++ b/packages/extension-dapp/src/util.ts @@ -0,0 +1,12 @@ +// Copyright 2019-2025 @pezkuwi/extension-dapp authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +export function documentReadyPromise (creator: () => Promise): Promise { + return new Promise((resolve): void => { + if (document.readyState === 'complete') { + resolve(creator()); + } else { + window.addEventListener('load', () => resolve(creator())); + } + }); +} diff --git a/packages/extension-dapp/src/wrapBytes.spec.ts b/packages/extension-dapp/src/wrapBytes.spec.ts new file mode 100644 index 0000000..b7e5687 --- /dev/null +++ b/packages/extension-dapp/src/wrapBytes.spec.ts @@ -0,0 +1,137 @@ +// Copyright 2019-2025 @pezkuwi/extension authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type * as _ from '@pezkuwi/dev-test/globals.d.ts'; + +import { u8aConcat, u8aEq, u8aToString } from '@pezkuwi/util'; + +import { ETHEREUM, POSTFIX, PREFIX, unwrapBytes, wrapBytes } from './wrapBytes.js'; + +const TEST_DATA = 'this is just some random message that we expect to be wrapped along the way'; +const TEST_ETH = u8aConcat(ETHEREUM, TEST_DATA); +const TEST_WRAP_EMPTY = `${u8aToString(PREFIX)}${u8aToString(POSTFIX)}`; +const TEST_WRAP_FULL = `${u8aToString(PREFIX)}${TEST_DATA}${u8aToString(POSTFIX)}`; +const TEST_WARP_HALF_PRE = `${u8aToString(PREFIX)}${TEST_DATA}`; +const TEST_WRAP_HALF_POST = `${TEST_DATA}${u8aToString(POSTFIX)}`; + +describe('wrapBytes', (): void => { + it('wraps empty bytes', (): void => { + expect( + u8aEq( + wrapBytes(new Uint8Array()), + u8aConcat(PREFIX, POSTFIX) + ) + ).toBe(true); + }); + + it('wraps when no wrapping is detected', (): void => { + expect( + u8aToString( + wrapBytes(TEST_DATA) + ) + ).toEqual(TEST_WRAP_FULL); + }); + + it('wraps when only start wrap is detected', (): void => { + expect( + u8aToString( + wrapBytes(TEST_WARP_HALF_PRE) + ) + ).toEqual(`${u8aToString(PREFIX)}${TEST_WARP_HALF_PRE}${u8aToString(POSTFIX)}`); + }); + + it('wraps when only end wrap is detected', (): void => { + expect( + u8aToString( + wrapBytes(TEST_WRAP_HALF_POST) + ) + ).toEqual(`${u8aToString(PREFIX)}${TEST_WRAP_HALF_POST}${u8aToString(POSTFIX)}`); + }); + + it('does not re-wrap when a wrap is already present', (): void => { + expect( + u8aToString( + wrapBytes(TEST_WRAP_FULL) + ) + ).toEqual(TEST_WRAP_FULL); + }); + + it('does not re-wrap when a wrap (empty data) is already present', (): void => { + expect( + u8aToString( + wrapBytes(TEST_WRAP_EMPTY) + ) + ).toEqual(TEST_WRAP_EMPTY); + }); +}); + +describe('unwrapBytes', (): void => { + it('unwraps empty bytes', (): void => { + expect( + u8aEq( + unwrapBytes(new Uint8Array()), + new Uint8Array() + ) + ).toBe(true); + }); + + it('unwraps when no wrapping is detected', (): void => { + expect( + u8aToString( + unwrapBytes(TEST_DATA) + ) + ).toEqual(TEST_DATA); + }); + + it('unwraps when no wrapping is detected (only start)', (): void => { + expect( + u8aToString( + unwrapBytes(TEST_WARP_HALF_PRE) + ) + ).toEqual(TEST_WARP_HALF_PRE); + }); + + it('unwraps when no wrapping is detected (only end)', (): void => { + expect( + u8aToString( + unwrapBytes(TEST_WRAP_HALF_POST) + ) + ).toEqual(TEST_WRAP_HALF_POST); + }); + + it('unwraps when a wrap is present', (): void => { + expect( + u8aToString( + unwrapBytes(TEST_WRAP_FULL) + ) + ).toEqual(TEST_DATA); + }); + + it('unwraps when a an empty wrap is present', (): void => { + expect( + u8aToString( + unwrapBytes(TEST_WRAP_EMPTY) + ) + ).toEqual(''); + }); + + describe('Ethereum-style', (): void => { + it('does not wrap an Ethereum wrap', (): void => { + expect( + u8aEq( + wrapBytes(TEST_ETH), + TEST_ETH + ) + ).toBe(true); + }); + + it('does not unwrap an Ethereum wrap', (): void => { + expect( + u8aEq( + unwrapBytes(TEST_ETH), + TEST_ETH + ) + ).toBe(true); + }); + }); +}); diff --git a/packages/extension-dapp/src/wrapBytes.ts b/packages/extension-dapp/src/wrapBytes.ts new file mode 100644 index 0000000..bb4d765 --- /dev/null +++ b/packages/extension-dapp/src/wrapBytes.ts @@ -0,0 +1,12 @@ +// Copyright 2019-2025 @pezkuwi/extension authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import { U8A_WRAP_ETHEREUM, U8A_WRAP_POSTFIX, U8A_WRAP_PREFIX, u8aIsWrapped, u8aUnwrapBytes, u8aWrapBytes } from '@pezkuwi/util'; + +export const ETHEREUM = U8A_WRAP_ETHEREUM; +export const POSTFIX = U8A_WRAP_POSTFIX; +export const PREFIX = U8A_WRAP_PREFIX; + +export const isWrapped = u8aIsWrapped; +export const unwrapBytes = u8aUnwrapBytes; +export const wrapBytes = u8aWrapBytes; diff --git a/packages/extension-dapp/tsconfig.build.json b/packages/extension-dapp/tsconfig.build.json new file mode 100644 index 0000000..e080ec5 --- /dev/null +++ b/packages/extension-dapp/tsconfig.build.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": "..", + "outDir": "./build", + "rootDir": "./src" + }, + "exclude": [ + "**/*.spec.ts" + ], + "references": [ + { "path": "../extension-inject/tsconfig.build.json" } + ] +} diff --git a/packages/extension-dapp/tsconfig.spec.json b/packages/extension-dapp/tsconfig.spec.json new file mode 100644 index 0000000..e3831fb --- /dev/null +++ b/packages/extension-dapp/tsconfig.spec.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": "..", + "outDir": "./build", + "rootDir": "./src", + "emitDeclarationOnly": false, + "noEmit": true + }, + "include": [ + "**/*.spec.ts" + ], + "references": [ + { "path": "../extension-dapp/tsconfig.build.json" } + ] +} diff --git a/packages/extension-inject/README.md b/packages/extension-inject/README.md new file mode 100644 index 0000000..460adaf --- /dev/null +++ b/packages/extension-inject/README.md @@ -0,0 +1,19 @@ +# @polkadot/extension-inject + +This is a basic extension injector that manages access to the global objects available. As an extension developer, you don't need to manage access to the window object manually, by just calling enable here, the global object is setup and managed properly. From here any dapp can access it with the `@polkadot/extension-dapp` package; + +## Usage + +```js +import { injectExtension } from '@polkadot/extension-inject'; + +// this a the function that will be exposed to be callable by the dapp. It resolves a promise +// with the injected interface, (see `Injected`) when the dapp at `originName` (url) is allowed +// to access functionality +function enableFn (originName: string): Promise { + ... +} + +// injects the extension into the page +injectExtension(enableFn, { name: 'myExtension', version: '1.0.1' }); +``` diff --git a/packages/extension-inject/package.json b/packages/extension-inject/package.json new file mode 100644 index 0000000..c21b939 --- /dev/null +++ b/packages/extension-inject/package.json @@ -0,0 +1,38 @@ +{ + "author": "Jaco Greeff ", + "bugs": "https://github.com/pezkuwichain/pezkuwi-extension/issues", + "description": "A generic injector (usable to any extension), that populates the base exposed interfaces to be used by dapps.", + "engines": { + "node": ">=18" + }, + "homepage": "https://github.com/pezkuwichain/pezkuwi-extension/tree/master/packages/extension-inject#readme", + "license": "Apache-2.0", + "name": "@pezkuwi/extension-inject", + "repository": { + "directory": "packages/extension-inject", + "type": "git", + "url": "https://github.com/pezkuwichain/pezkuwi-extension.git" + }, + "sideEffects": true, + "type": "module", + "version": "0.62.6", + "main": "index.js", + "dependencies": { + "@pezkuwi/api": "^16.5.5", + "@pezkuwi/rpc-provider": "^16.5.5", + "@pezkuwi/types": "^16.5.5", + "@pezkuwi/util": "^14.0.5", + "@pezkuwi/util-crypto": "^14.0.5", + "@pezkuwi/x-global": "^14.0.5", + "tslib": "^2.8.1" + }, + "devDependencies": { + "@pezkuwi/dev-test": "^0.84.3", + "@types/chrome": "^0.0.254", + "@types/firefox-webext-browser": "^111.0.5" + }, + "peerDependencies": { + "@pezkuwi/api": "*", + "@pezkuwi/util": "*" + } +} diff --git a/packages/extension-inject/src/bundle.ts b/packages/extension-inject/src/bundle.ts new file mode 100644 index 0000000..ed262fe --- /dev/null +++ b/packages/extension-inject/src/bundle.ts @@ -0,0 +1,47 @@ +// Copyright 2019-2025 @pezkuwi/extension-inject authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { Injected, InjectedExtension, InjectedWindow, InjectOptions } from './types.js'; + +import { cyrb53 } from './cyrb53.js'; + +export { packageInfo } from './packageInfo.js'; + +// setting for new-style connect (more-secure with no details exposed without +// user acknowledgement, however slightly less-compatible with all dapps, some +// may have not upgraded and don't have access to the latest interfaces) +// +// NOTE: In future versions this will be made the default +const IS_CONNECT_CAPABLE = false; + +// It is recommended to always use the function below to shield the extension and dapp from +// any future changes. The exposed interface will manage access between the 2 environments, +// be it via window (current), postMessage (under consideration) or any other mechanism +export function injectExtension (enable: (origin: string) => Promise, { name, version }: InjectOptions): void { + // small helper with the typescript types, just cast window + const windowInject = window as Window & InjectedWindow; + + // don't clobber the existing object, we will add it (or create as needed) + windowInject.injectedWeb3 = windowInject.injectedWeb3 || {}; + + if (IS_CONNECT_CAPABLE) { + // expose our extension on the window object, new-style with connect(origin) + windowInject.injectedWeb3[cyrb53(`${name}/${version}`)] = { + connect: (origin: string): Promise => + enable(origin).then(({ accounts, metadata, provider, signer }) => ({ + accounts, metadata, name, provider, signer, version + })), + enable: (): Promise => + Promise.reject( + new Error('This extension does not have support for enable(...), rather is only supports the new connect(...) variant (no extension name/version metadata without specific user-approval)') + ) + }; + } else { + // expose our extension on the window object, old-style with enable(origin) + windowInject.injectedWeb3[name] = { + enable: (origin: string): Promise => + enable(origin), + version + }; + } +} diff --git a/packages/extension-inject/src/chrome.ts b/packages/extension-inject/src/chrome.ts new file mode 100644 index 0000000..3be36f3 --- /dev/null +++ b/packages/extension-inject/src/chrome.ts @@ -0,0 +1,6 @@ +// Copyright 2019-2025 @pezkuwi/extension-inject authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import { extractGlobal, xglobal } from '@pezkuwi/x-global'; + +export const chrome = extractGlobal('chrome', xglobal.browser); diff --git a/packages/extension-inject/src/crossenv.ts b/packages/extension-inject/src/crossenv.ts new file mode 100644 index 0000000..89f0e3d --- /dev/null +++ b/packages/extension-inject/src/crossenv.ts @@ -0,0 +1,6 @@ +// Copyright 2019-2025 @pezkuwi/extension-inject authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import { exposeGlobal, xglobal } from '@pezkuwi/x-global'; + +exposeGlobal('chrome', xglobal.browser); diff --git a/packages/extension-inject/src/cyrb53.spec.ts b/packages/extension-inject/src/cyrb53.spec.ts new file mode 100644 index 0000000..db4742f --- /dev/null +++ b/packages/extension-inject/src/cyrb53.spec.ts @@ -0,0 +1,30 @@ +// Copyright 2019-2025 @pezkuwi/extension-inject authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type * as _ from '@pezkuwi/dev-test/globals.d.ts'; + +import { cyrb53 } from './cyrb53.js'; + +const TESTS = [ + { input: 'a', seed: 0, test: '501c2ba782c97901' }, + { input: 'b', seed: 0, test: '459eda5bc254d2bf' }, + { input: 'revenge', seed: 0, test: 'fbce64cc3b748385' }, + { input: 'revenue', seed: 0, test: 'fb1d85148d13f93a' }, + { input: 'revenue', seed: 1, test: '76fee5e6598ccd5c' }, + { input: 'revenue', seed: 2, test: '1f672e2831253862' }, + { input: 'revenue', seed: 3, test: '2b10de31708e6ab7' } +] as const; + +describe('cyrb53', (): void => { + for (let i = 0, count = TESTS.length; i < count; i++) { + const { input, seed, test } = TESTS[i]; + + it(`correctly encodes ${input}`, (): void => { + // expected values, known input & seed + expect(cyrb53(input, seed)).toEqual(test); + + // seed set as Date.now(), should not match previous + expect(cyrb53(input)).not.toEqual(test); + }); + } +}); diff --git a/packages/extension-inject/src/cyrb53.ts b/packages/extension-inject/src/cyrb53.ts new file mode 100644 index 0000000..89b7f1d --- /dev/null +++ b/packages/extension-inject/src/cyrb53.ts @@ -0,0 +1,33 @@ +// Copyright 2019-2025 @pezkuwi/extension-inject authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +// cyrb53 (c) 2018 bryc (github.com/bryc) +// A fast and simple hash function with decent collision resistance. +// Largely inspired by MurmurHash2/3, but with a focus on speed/simplicity. +// Public domain. Attribution appreciated. +// +// From https://github.com/bryc/code/blob/fed42df9db547493452e32375c93d7854383e480/jshash/experimental/cyrb53.js +// As shared in https://stackoverflow.com/a/52171480 +// +// Small changes made to the code as linked above: +// - Seed value is required (set as Date.now() in usage, could change) +// - Return value is a hex string (as per comment in SO answer) +// - TS typings added +// - Non-intrusive coding-style variable declaration changes +export function cyrb53 (input: string, seed = Date.now()): string { + let h1 = 0xdeadbeef ^ seed; + let h2 = 0x41c6ce57 ^ seed; + + for (let i = 0, count = input.length; i < count; i++) { + const ch = input.charCodeAt(i); + + h1 = Math.imul(h1 ^ ch, 2654435761); + h2 = Math.imul(h2 ^ ch, 1597334677); + } + + h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909); + h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909); + + // https://stackoverflow.com/a/52171480 + return (h2 >>> 0).toString(16).padStart(8, '0') + (h1 >>> 0).toString(16).padStart(8, '0'); +} diff --git a/packages/extension-inject/src/index.ts b/packages/extension-inject/src/index.ts new file mode 100644 index 0000000..e53cf8f --- /dev/null +++ b/packages/extension-inject/src/index.ts @@ -0,0 +1,7 @@ +// Copyright 2019-2025 @pezkuwi/extension-inject authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +// Since we inject into pages, we skip this +// import './packageDetect.js'; + +export * from './bundle.js'; diff --git a/packages/extension-inject/src/packageDetect.ts b/packages/extension-inject/src/packageDetect.ts new file mode 100644 index 0000000..9abb951 --- /dev/null +++ b/packages/extension-inject/src/packageDetect.ts @@ -0,0 +1,11 @@ +// Copyright 2017-2025 @pezkuwi/extension-inject authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +// Do not edit, auto-generated by @pezkuwi/dev +// (packageInfo imports will be kept as-is, user-editable) + +import { detectPackage } from '@pezkuwi/util'; + +import { packageInfo } from './packageInfo.js'; + +detectPackage(packageInfo, null, []); diff --git a/packages/extension-inject/src/packageInfo.ts b/packages/extension-inject/src/packageInfo.ts new file mode 100644 index 0000000..29a8e1e --- /dev/null +++ b/packages/extension-inject/src/packageInfo.ts @@ -0,0 +1,6 @@ +// Copyright 2017-2025 @pezkuwi/extension-inject authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +// Do not edit, auto-generated by @pezkuwi/dev + +export const packageInfo = { name: '@pezkuwi/extension-inject', path: 'auto', type: 'auto', version: '0.62.6' }; diff --git a/packages/extension-inject/src/types.ts b/packages/extension-inject/src/types.ts new file mode 100644 index 0000000..6d95e03 --- /dev/null +++ b/packages/extension-inject/src/types.ts @@ -0,0 +1,122 @@ +// Copyright 2019-2025 @pezkuwi/extension-inject authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { Signer as InjectedSigner } from '@pezkuwi/api/types'; +import type { ProviderInterface } from '@pezkuwi/rpc-provider/types'; +import type { ExtDef } from '@pezkuwi/types/extrinsic/signedExtensions/types'; +import type { HexString } from '@pezkuwi/util/types'; +import type { KeypairType } from '@pezkuwi/util-crypto/types'; + +// eslint-disable-next-line no-undef +type This = typeof globalThis; + +export type Unsubcall = () => void; + +export interface InjectedAccount { + address: string; + genesisHash?: string | null; + name?: string; + type?: KeypairType; +} + +export interface InjectedAccountWithMeta { + address: string; + meta: { + genesisHash?: string | null; + name?: string; + source: string; + }; + type?: KeypairType; +} + +export interface InjectedAccounts { + get: (anyType?: boolean) => Promise; + subscribe: (cb: (accounts: InjectedAccount[]) => void | Promise) => Unsubcall; +} + +export interface InjectedExtensionInfo { + name: string; + version: string; +} + +// Metadata about a provider +export interface ProviderMeta { + // Network of the provider + network: string; + // Light or full node + node: 'full' | 'light'; + // The extension source + source: string; + // Provider transport: 'WsProvider' etc. + transport: string; +} + +export interface MetadataDefBase { + chain: string; + genesisHash: HexString; + icon: string; + ss58Format: number; + chainType?: 'substrate' | 'ethereum' +} + +export interface MetadataDef extends MetadataDefBase { + color?: string; + specVersion: number; + tokenDecimals: number; + tokenSymbol: string; + types: Record | string>; + metaCalls?: string; + rawMetadata?: HexString; + userExtensions?: ExtDef; +} + +export interface InjectedMetadataKnown { + genesisHash: string; + specVersion: number; +} + +export interface InjectedMetadata { + get: () => Promise; + provide: (definition: MetadataDef) => Promise; +} + +export type ProviderList = Record + +export interface InjectedProvider extends ProviderInterface { + listProviders: () => Promise; + startProvider: (key: string) => Promise; +} + +export interface InjectedProviderWithMeta { + // provider will actually always be a PostMessageProvider, which implements InjectedProvider + provider: InjectedProvider; + meta: ProviderMeta; +} + +export interface Injected { + accounts: InjectedAccounts; + metadata?: InjectedMetadata; + provider?: InjectedProvider; + signer: InjectedSigner; +} + +export interface InjectedWindowProvider { + connect?: (origin: string) => Promise; + enable?: (origin: string) => Promise; + version?: string; +} + +export interface InjectedWindow extends This { + injectedWeb3: Record; +} + +export type InjectedExtension = InjectedExtensionInfo & Injected; + +export type InjectOptions = InjectedExtensionInfo; + +export interface Web3AccountsOptions { + accountType?: KeypairType[]; + extensions?: string[]; + genesisHash?: string | null; + ss58Format?: number; +} diff --git a/packages/extension-inject/tsconfig.build.json b/packages/extension-inject/tsconfig.build.json new file mode 100644 index 0000000..e69f4d9 --- /dev/null +++ b/packages/extension-inject/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": "..", + "outDir": "./build", + "rootDir": "./src" + }, + "exclude": [ + "**/*.spec.ts" + ], + "references": [] +} diff --git a/packages/extension-inject/tsconfig.spec.json b/packages/extension-inject/tsconfig.spec.json new file mode 100644 index 0000000..71355f9 --- /dev/null +++ b/packages/extension-inject/tsconfig.spec.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": "..", + "outDir": "./build", + "rootDir": "./src", + "emitDeclarationOnly": false, + "noEmit": true + }, + "include": [ + "**/*.spec.ts" + ], + "references": [ + { "path": "../extension-inject/tsconfig.build.json" } + ] +} diff --git a/packages/extension-mocks/.skip-build b/packages/extension-mocks/.skip-build new file mode 100644 index 0000000..e69de29 diff --git a/packages/extension-mocks/.skip-npm b/packages/extension-mocks/.skip-npm new file mode 100644 index 0000000..e69de29 diff --git a/packages/extension-mocks/README.md b/packages/extension-mocks/README.md new file mode 100644 index 0000000..5f45688 --- /dev/null +++ b/packages/extension-mocks/README.md @@ -0,0 +1,3 @@ +# @polkadot/extension-chains + +Definitions for chains that are supported by this extension. It contains the bare definitions as well as a stripped-down (call-only) metadata format. diff --git a/packages/extension-mocks/package.json b/packages/extension-mocks/package.json new file mode 100644 index 0000000..9d8e229 --- /dev/null +++ b/packages/extension-mocks/package.json @@ -0,0 +1,24 @@ +{ + "author": "Jaco Greeff ", + "bugs": "https://github.com/pezkuwichain/pezkuwi-extension/issues", + "description": "Definitions for all known chains as exposed by the extension.", + "engines": { + "node": ">=18" + }, + "homepage": "https://github.com/pezkuwichain/pezkuwi-extension/tree/master/packages/extension-mocks#readme", + "license": "Apache-2.0", + "name": "@pezkuwi/extension-mocks", + "repository": { + "directory": "packages/extension-mocks", + "type": "git", + "url": "https://github.com/pezkuwichain/pezkuwi-extension.git" + }, + "sideEffects": false, + "type": "module", + "version": "0.62.6", + "main": "index.js", + "dependencies": { + "sinon-chrome": "^3.0.1", + "tslib": "^2.8.1" + } +} diff --git a/packages/extension-mocks/src/chrome.ts b/packages/extension-mocks/src/chrome.ts new file mode 100644 index 0000000..9a3e317 --- /dev/null +++ b/packages/extension-mocks/src/chrome.ts @@ -0,0 +1,64 @@ +// Copyright 2019-2025 @pezkuwi/extension-mocks authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/* eslint-disable @typescript-eslint/no-explicit-any */ +import chrome from './chromeWrapper'; + +class MessagingFake { + private listeners: ((...params: unknown[]) => unknown)[] = []; + + get onMessage (): any { + return { + addListener: (cb: (...params: unknown[]) => unknown) => this.listeners.push(cb) + }; + } + + get onDisconnect (): any { + return { + addListener: (): any => undefined + }; + } + + postMessage (data: unknown): void { + this.listeners.forEach((cb) => cb.call(this, data)); + } +} + +const messagingFake = new MessagingFake(); + +chrome.runtime.connect.returns(messagingFake); + +chrome.storage.local.get.returns( + new Promise>((resolve, reject) => { + try { + const result = { + authUrls: JSON.stringify({ + 'http://localhost:3000': { + authorizedAccounts: ['5FbSap4BsWfjyRhCchoVdZHkDnmDm3NEgLZ25mesq4aw2WvX'], + count: 0, + id: '11', + origin: 'example.com', + url: 'http://localhost:3000' + } + }) + }; + + resolve(result); + } catch (error) { + reject(error); + } + })); + +chrome.storage.local.set.returns( + new Promise((resolve, reject) => { + try { + resolve(); + } catch (error) { + reject(error); + } + })); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-member-access +(window as any).chrome = (globalThis as any).chrome = chrome; + +export default chrome; diff --git a/packages/extension-mocks/src/chromeWrapper.ts b/packages/extension-mocks/src/chromeWrapper.ts new file mode 100644 index 0000000..efe1b68 --- /dev/null +++ b/packages/extension-mocks/src/chromeWrapper.ts @@ -0,0 +1,24 @@ +// Copyright 2019-2025 @pezkuwi/extension-mocks authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import sinonChrome from 'sinon-chrome'; + +// eslint-disable-next-line @typescript-eslint/no-namespace +namespace ChromeWrapper { + export interface IAction { + setBadgeText: (content: object) => Promise; + } + export const action: IAction = { + setBadgeText: (_: object) => { + return new Promise((resolve, _reject) => { + resolve(); + }); + } + }; +} +const extendedSinonChrome = { + ...sinonChrome, + action: ChromeWrapper.action +}; + +export default extendedSinonChrome; diff --git a/packages/extension-mocks/src/empty.js b/packages/extension-mocks/src/empty.js new file mode 100644 index 0000000..918342a --- /dev/null +++ b/packages/extension-mocks/src/empty.js @@ -0,0 +1,4 @@ +// Copyright 2019-2025 @pezkuwi/extension-mocks authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +export default ''; diff --git a/packages/extension-mocks/src/fileMock.cjs b/packages/extension-mocks/src/fileMock.cjs new file mode 100644 index 0000000..5a8c732 --- /dev/null +++ b/packages/extension-mocks/src/fileMock.cjs @@ -0,0 +1,5 @@ +// Copyright 2019-2025 @polkadot/extension-mocks authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +// eslint-disable-line +module.exports = ''; diff --git a/packages/extension-mocks/src/loader-empty.js b/packages/extension-mocks/src/loader-empty.js new file mode 100644 index 0000000..5cee03b --- /dev/null +++ b/packages/extension-mocks/src/loader-empty.js @@ -0,0 +1,28 @@ +// Copyright 2019-2025 @pezkuwi/extension-mocks authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import path from 'node:path'; +import process from 'node:process'; +import { pathToFileURL } from 'node:url'; + +/** + * Adjusts the resolver to point to empty files for .svg + * + * @param {*} specifier + * @param {*} context + * @param {*} nextResolve + * @returns {*} + */ +export function resolve (specifier, context, nextResolve) { + if (/\.(png|svg)$/.test(specifier)) { + return { + format: 'module', + shortCircuit: true, + url: pathToFileURL( + path.join(process.cwd(), 'packages/extension-mocks/src/empty.js') + ).href + }; + } + + return nextResolve(specifier, context); +} diff --git a/packages/extension-mocks/src/react-i18next.ts b/packages/extension-mocks/src/react-i18next.ts new file mode 100644 index 0000000..52a5cc8 --- /dev/null +++ b/packages/extension-mocks/src/react-i18next.ts @@ -0,0 +1,24 @@ +// Copyright 2019-2025 @pezkuwi/extension-mocks authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type React from 'react'; + +interface useTranslationReturnObj { + i18n: { changeLanguage: () => Promise; }; + t: (str: string) => string; +} + +export const useTranslation = (): useTranslationReturnObj => { + return { + i18n: { + changeLanguage: () => new Promise(() => { /**/ }) + }, + t: (str: string) => str + }; +}; + +export const withTranslation = () => (component: React.ReactElement): React.ReactElement => component; + +export const Trans = ({ children }: { children: React.ReactElement }): React.ReactElement => children; + +export default withTranslation; diff --git a/packages/extension-mocks/tsconfig.build.json b/packages/extension-mocks/tsconfig.build.json new file mode 100644 index 0000000..b27ae67 --- /dev/null +++ b/packages/extension-mocks/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": "..", + "outDir": "./build", + "rootDir": "./src" + }, + "references": [ + { "path": "../extension-inject/tsconfig.build.json" } + ] +} diff --git a/packages/extension-ui/.skip-build b/packages/extension-ui/.skip-build new file mode 100644 index 0000000..e69de29 diff --git a/packages/extension-ui/.skip-npm b/packages/extension-ui/.skip-npm new file mode 100644 index 0000000..e69de29 diff --git a/packages/extension-ui/README.md b/packages/extension-ui/README.md new file mode 100644 index 0000000..794aebb --- /dev/null +++ b/packages/extension-ui/README.md @@ -0,0 +1,3 @@ +# @polkadot/extension-ui + +UI for the `@polkadot/extension` diff --git a/packages/extension-ui/package.json b/packages/extension-ui/package.json new file mode 100644 index 0000000..2a9ed2e --- /dev/null +++ b/packages/extension-ui/package.json @@ -0,0 +1,74 @@ +{ + "author": "Jaco Greeff ", + "bugs": "https://github.com/pezkuwichain/pezkuwi-extension/issues", + "description": "A sample signer extension for the @pezkuwi/api", + "engines": { + "node": ">=18" + }, + "homepage": "https://github.com/pezkuwichain/pezkuwi-extension/tree/master/packages/extension-ui#readme", + "license": "Apache-2.0", + "name": "@pezkuwi/extension-ui", + "repository": { + "directory": "packages/extension-ui", + "type": "git", + "url": "https://github.com/pezkuwichain/pezkuwi-extension.git" + }, + "sideEffects": true, + "type": "module", + "version": "0.62.6", + "dependencies": { + "@fortawesome/fontawesome-svg-core": "^6.7.2", + "@fortawesome/free-brands-svg-icons": "^6.7.2", + "@fortawesome/free-regular-svg-icons": "^6.7.2", + "@fortawesome/free-solid-svg-icons": "^6.7.2", + "@fortawesome/react-fontawesome": "^0.2.0", + "@paraspell/xcm-analyser": "^1.3.1", + "@polkadot-api/merkleize-metadata": "^1.1.27", + "@pezkuwi/api": "^16.5.3", + "@pezkuwi/extension-base": "0.62.6", + "@pezkuwi/extension-chains": "0.62.6", + "@pezkuwi/extension-dapp": "0.62.6", + "@pezkuwi/extension-inject": "0.62.6", + "@pezkuwi/hw-ledger": "^14.0.5", + "@pezkuwi/keyring": "^14.0.5", + "@pezkuwi/networks": "^14.0.5", + "@pezkuwi/react-identicon": "^3.16.4", + "@pezkuwi/react-qr": "^3.16.4", + "@pezkuwi/types": "^16.5.3", + "@pezkuwi/types-augment": "^16.5.3", + "@pezkuwi/ui-keyring": "^3.16.4", + "@pezkuwi/ui-settings": "^3.16.4", + "@pezkuwi/util": "^14.0.5", + "@pezkuwi/util-crypto": "^14.0.5", + "@zxcvbn-ts/core": "^3.0.4", + "@zxcvbn-ts/language-common": "^3.0.4", + "@zxcvbn-ts/language-en": "^3.0.2", + "file-saver": "^2.0.5", + "i18next": "^23.7.11", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-dropzone": "^11.7.1", + "react-i18next": "^13.5.0", + "react-is": "^18.2.0", + "react-router": "^5.3.4", + "react-router-dom": "^5.3.4", + "styled-components": "^6.1.1", + "tslib": "^2.8.1" + }, + "devDependencies": { + "@pezkuwi/dev-test": "^0.84.3", + "@pezkuwi/extension-mocks": "0.62.6", + "@types/enzyme": "^3.10.18", + "@types/enzyme-adapter-react-16": "^1.0.9", + "@types/file-saver": "^2.0.7", + "@types/react-copy-to-clipboard": "^5.0.7", + "@types/react-dom": "^18.2.18", + "@types/react-router": "^5.1.20", + "@types/react-router-dom": "^5.3.3", + "@types/sinon-chrome": "^2.2.15", + "@wojtekmaj/enzyme-adapter-react-17": "^0.8.0", + "enzyme": "^3.11.0", + "enzyme-adapter-react-16": "^1.15.7", + "sinon-chrome": "^3.0.1" + } +} diff --git a/packages/extension-ui/src/MetadataCache.ts b/packages/extension-ui/src/MetadataCache.ts new file mode 100644 index 0000000..29947fc --- /dev/null +++ b/packages/extension-ui/src/MetadataCache.ts @@ -0,0 +1,14 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { MetadataDef } from '@pezkuwi/extension-inject/types'; + +const metadataGets = new Map>(); + +export function getSavedMeta (genesisHash: string): Promise | undefined { + return metadataGets.get(genesisHash); +} + +export function setSavedMeta (genesisHash: string, def: Promise): Map> { + return metadataGets.set(genesisHash, def); +} diff --git a/packages/extension-ui/src/Popup/Accounts/Account.spec.tsx b/packages/extension-ui/src/Popup/Accounts/Account.spec.tsx new file mode 100644 index 0000000..c38eb02 --- /dev/null +++ b/packages/extension-ui/src/Popup/Accounts/Account.spec.tsx @@ -0,0 +1,91 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import '@pezkuwi/extension-mocks/chrome'; + +import type { ReactWrapper } from 'enzyme'; +import type * as _ from '@pezkuwi/dev-test/globals.d.ts'; + +import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; +import enzyme from 'enzyme'; +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { MemoryRouter } from 'react-router'; + +import * as messaging from '../../messaging.js'; +import { flushAllPromises } from '../../testHelpers.js'; +import Account from './Account.js'; + +const { configure, mount } = enzyme; + +// // NOTE Required for spyOn when using @swc/jest +// // https://github.com/swc-project/swc/issues/3843 +// jest.mock('../../messaging', (): Record => ({ +// __esModule: true, +// ...jest.requireActual('../../messaging') +// })); + +// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-call +configure({ adapter: new Adapter() }); + +jest.spyOn(messaging, 'getAllMetadata').mockImplementation(() => Promise.resolve([])); + +describe('Account component', () => { + let wrapper: ReactWrapper; + const VALID_ADDRESS = 'HjoBp62cvsWDA3vtNMWxz6c9q13ReEHi9UGHK7JbZweH5g5'; + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + const mountAccountComponent = (additionalAccountProperties: Record): ReactWrapper => mount( + + + ); + + it('shows Export option if account is not external', async () => { + wrapper = mountAccountComponent({ isExternal: false, type: 'ed25519' }); + wrapper.find('.settings').first().simulate('click'); + await act(flushAllPromises); + + expect(wrapper.find('a.menuItem').length).toBe(4); + expect(wrapper.find('a.menuItem').at(0).text()).toBe('Rename'); + expect(wrapper.find('a.menuItem').at(1).text()).toBe('Derive New Account'); + expect(wrapper.find('a.menuItem').at(2).text()).toBe('Export Account'); + expect(wrapper.find('a.menuItem').at(3).text()).toBe('Forget Account'); + expect(wrapper.find('.genesisSelection').exists()).toBe(true); + }); + + it('does not show Export option if account is external', async () => { + wrapper = mountAccountComponent({ isExternal: true, type: 'ed25519' }); + wrapper.find('.settings').first().simulate('click'); + await act(flushAllPromises); + + expect(wrapper.find('a.menuItem').length).toBe(2); + expect(wrapper.find('a.menuItem').at(0).text()).toBe('Rename'); + expect(wrapper.find('a.menuItem').at(1).text()).toBe('Forget Account'); + expect(wrapper.find('.genesisSelection').exists()).toBe(true); + }); + + it('shows Derive option if account is of ethereum type', async () => { + wrapper = mountAccountComponent({ isExternal: false, type: 'ethereum' }); + wrapper.find('.settings').first().simulate('click'); + await act(flushAllPromises); + + expect(wrapper.find('a.menuItem').length).toBe(4); + expect(wrapper.find('a.menuItem').at(0).text()).toBe('Rename'); + expect(wrapper.find('a.menuItem').at(1).text()).toBe('Derive New Account'); + expect(wrapper.find('a.menuItem').at(2).text()).toBe('Export Account'); + expect(wrapper.find('a.menuItem').at(3).text()).toBe('Forget Account'); + expect(wrapper.find('.genesisSelection').exists()).toBe(true); + }); + + it('does not show genesis hash selection dropsown if account is hardware', async () => { + wrapper = mountAccountComponent({ isExternal: true, isHardware: true }); + wrapper.find('.settings').first().simulate('click'); + await act(flushAllPromises); + + expect(wrapper.find('a.menuItem').length).toBe(2); + expect(wrapper.find('a.menuItem').at(0).text()).toBe('Rename'); + expect(wrapper.find('a.menuItem').at(1).text()).toBe('Forget Account'); + expect(wrapper.find('.genesisSelection').exists()).toBe(false); + }); +}); diff --git a/packages/extension-ui/src/Popup/Accounts/Account.tsx b/packages/extension-ui/src/Popup/Accounts/Account.tsx new file mode 100644 index 0000000..9c5dd23 --- /dev/null +++ b/packages/extension-ui/src/Popup/Accounts/Account.tsx @@ -0,0 +1,204 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { AccountJson } from '@pezkuwi/extension-base/background/types'; +import type { HexString } from '@pezkuwi/util/types'; + +import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react'; + +import { canDerive } from '@pezkuwi/extension-base/utils'; + +import { AccountContext, Address, Checkbox, Dropdown, Link, MenuDivider } from '../../components/index.js'; +import { useGenesisHashOptions, useTranslation } from '../../hooks/index.js'; +import { editAccount, tieAccount } from '../../messaging.js'; +import { Name } from '../../partials/index.js'; +import { styled } from '../../styled.js'; + +interface Props extends AccountJson { + className?: string; + parentName?: string; + showVisibilityAction?: boolean; + withCheckbox?: boolean; + withMenu?: boolean +} + +interface EditState { + isEditing: boolean; + toggleActions: number; +} + +function Account ({ address, className, genesisHash, isExternal, isHardware, isHidden, name, parentName, showVisibilityAction, suri, type, withCheckbox = false, withMenu = true }: Props): React.ReactElement { + const { t } = useTranslation(); + const [{ isEditing, toggleActions }, setEditing] = useState({ isEditing: false, toggleActions: 0 }); + const [editedName, setName] = useState(name); + const [checked, setChecked] = useState(false); + const genesisOptions = useGenesisHashOptions(); + const { selectedAccounts = [], setSelectedAccounts } = useContext(AccountContext); + const isSelected = useMemo(() => selectedAccounts?.includes(address) || false, [address, selectedAccounts]); + + useEffect(() => { + setChecked(isSelected); + }, [isSelected]); + + const _onCheckboxChange = useCallback(() => { + const newList = selectedAccounts?.includes(address) + ? selectedAccounts.filter((account) => account !== address) + : [...selectedAccounts, address]; + + setSelectedAccounts && setSelectedAccounts(newList); + }, [address, selectedAccounts, setSelectedAccounts]); + + const _onChangeGenesis = useCallback( + (genesisHash?: HexString | null): void => { + tieAccount(address, genesisHash ?? null) + .catch(console.error); + }, + [address] + ); + + const _toggleEdit = useCallback( + (): void => setEditing(({ toggleActions }) => ({ isEditing: !isEditing, toggleActions: ++toggleActions })), + [isEditing] + ); + + const _saveChanges = useCallback( + (): void => { + editedName && + editAccount(address, editedName) + .catch(console.error); + + _toggleEdit(); + }, + [editedName, address, _toggleEdit] + ); + + const _actions = useMemo(() => ( + <> + + {t('Rename')} + + {!isExternal && canDerive(type) && ( + + {t('Derive New Account')} + + )} + + {!isExternal && ( + + {t('Export Account')} + + )} + + {t('Forget Account')} + + {!isHardware && ( + <> + +
+ +
+ + )} + + ), [_onChangeGenesis, _toggleEdit, address, genesisHash, genesisOptions, isExternal, isHardware, t, type]); + + return ( +
+ {withCheckbox && ( + + )} +
+ {isEditing && ( + + )} +
+
+ ); +} + +export default styled(Account)` + .address { + margin-bottom: 8px; + } + + .editName { + position: absolute; + flex: 1; + left: 70px; + top: 10px; + width: 350px; + + .danger { + background-color: var(--bodyColor); + margin-top: -13px; + width: 330px; + } + + input { + height : 30px; + width: 350px; + } + + &.withParent { + top: 16px + } + } + + .menuItem { + border-radius: 8px; + display: block; + font-size: 15px; + line-height: 20px; + margin: 0; + min-width: 13rem; + padding: 4px 16px; + + .genesisSelection { + margin: 0; + } + } +`; diff --git a/packages/extension-ui/src/Popup/Accounts/AccountsTree.tsx b/packages/extension-ui/src/Popup/Accounts/AccountsTree.tsx new file mode 100644 index 0000000..736a9ee --- /dev/null +++ b/packages/extension-ui/src/Popup/Accounts/AccountsTree.tsx @@ -0,0 +1,60 @@ +// Copyright 2019-2025 @pezkuwi/extension authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { AccountWithChildren } from '@pezkuwi/extension-base/background/types'; + +import React from 'react'; + +import { styled } from '../../styled.js'; +import Account from './Account.js'; + +interface Props extends AccountWithChildren { + className?: string + parentName?: string; + withCheckbox?: boolean + withMenu?: boolean + showHidden?: boolean +} + +function AccountsTree ({ className, parentName, showHidden = true, suri, withCheckbox = false, withMenu = true, ...account }: Props): React.ReactElement { + return ( +
+ { (showHidden || !account.isHidden) && ( + + )} + {account?.children?.map((child, index) => ( + + ))} +
+ ); +} + +export default styled(AccountsTree)` + .accountWichCheckbox { + display: flex; + align-items: center; + + & .address { + flex: 1; + } + + & .accountTree-checkbox label span { + top: -12px; + } + } +`; diff --git a/packages/extension-ui/src/Popup/Accounts/AddAccount.tsx b/packages/extension-ui/src/Popup/Accounts/AddAccount.tsx new file mode 100644 index 0000000..e7ed825 --- /dev/null +++ b/packages/extension-ui/src/Popup/Accounts/AddAccount.tsx @@ -0,0 +1,68 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import React, { useCallback, useContext } from 'react'; + +import { ActionContext } from '../../components/index.js'; +import { useTranslation } from '../../hooks/index.js'; +import Header from '../../partials/Header.js'; +import { styled } from '../../styled.js'; +import AddAccountImage from './AddAccountImage.js'; + +interface Props { + className?: string; +} + +function AddAccount ({ className }: Props): React.ReactElement { + const { t } = useTranslation(); + const onAction = useContext(ActionContext); + const _onClick = useCallback( + () => onAction('/account/create'), + [onAction] + ); + + return ( + <> +
+
+
+ +
+
+

{t("You currently don't have any accounts. Create your first account to get started.")}

+
+
+ + ); +} + +export default React.memo(styled(AddAccount)` + color: var(--textColor); + height: 100%; + + h3 { + color: var(--textColor); + margin-top: 0; + font-weight: normal; + font-size: 24px; + line-height: 33px; + text-align: center; + } + + > .image { + display: flex; + justify-content: center; + } + + > .no-accounts p { + text-align: center; + font-size: 16px; + line-height: 26px; + margin: 0 30px; + color: var(--subTextColor); + } +`); diff --git a/packages/extension-ui/src/Popup/Accounts/AddAccountImage.tsx b/packages/extension-ui/src/Popup/Accounts/AddAccountImage.tsx new file mode 100644 index 0000000..c225d6a --- /dev/null +++ b/packages/extension-ui/src/Popup/Accounts/AddAccountImage.tsx @@ -0,0 +1,99 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +// We _could_ reformat, but just keep it as-is, since this is actually +// externally generated and not really user-editable + +/* eslint-disable react/jsx-sort-props */ +/* eslint-disable react/jsx-max-props-per-line */ + +import React from 'react'; + +import { styled } from '../../styled.js'; + +interface Props { + className?: string; + onClick: () => void; +} + +function AddAccountImage ({ className, onClick }: Props): React.ReactElement { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default React.memo(styled(AddAccountImage)` + circle, path { + cursor: pointer; + } + + path { + fill: var(--textColor); + } + + & > g > circle { + stroke: var(--inputBorderColor); + fill: var(--addAccountImageBackground); + } +`); diff --git a/packages/extension-ui/src/Popup/Accounts/index.tsx b/packages/extension-ui/src/Popup/Accounts/index.tsx new file mode 100644 index 0000000..72d34f2 --- /dev/null +++ b/packages/extension-ui/src/Popup/Accounts/index.tsx @@ -0,0 +1,83 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { AccountWithChildren } from '@pezkuwi/extension-base/background/types'; + +import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react'; + +import getNetworkMap from '@pezkuwi/extension-ui/util/getNetworkMap'; + +import { AccountContext } from '../../components/index.js'; +import { useTranslation } from '../../hooks/index.js'; +import { Header } from '../../partials/index.js'; +import { styled } from '../../styled.js'; +import AccountsTree from './AccountsTree.js'; +import AddAccount from './AddAccount.js'; + +interface Props { + className?: string; +} + +function Accounts ({ className }: Props): React.ReactElement { + const { t } = useTranslation(); + const [filter, setFilter] = useState(''); + const [filteredAccount, setFilteredAccount] = useState([]); + const { hierarchy } = useContext(AccountContext); + const networkMap = useMemo(() => getNetworkMap(), []); + + useEffect(() => { + setFilteredAccount( + filter + ? hierarchy.filter((account) => + account.name?.toLowerCase().includes(filter) || + (account.genesisHash && networkMap.get(account.genesisHash)?.toLowerCase().includes(filter)) || + account.address.toLowerCase().includes(filter) + ) + : hierarchy + ); + }, [filter, hierarchy, networkMap]); + + const _onFilter = useCallback((filter: string) => { + setFilter(filter.toLowerCase()); + }, []); + + return ( + <> + {(hierarchy.length === 0) + ? + : ( + <> +
+
+ {filteredAccount.map((json, index): React.ReactNode => ( + + ))} +
+ + ) + } + + ); +} + +export default styled(Accounts)` + height: calc(100vh - 2px); + overflow-y: scroll; + margin-top: -25px; + padding-top: 25px; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } +`; diff --git a/packages/extension-ui/src/Popup/AssetHubMigration.tsx b/packages/extension-ui/src/Popup/AssetHubMigration.tsx new file mode 100644 index 0000000..a12dec7 --- /dev/null +++ b/packages/extension-ui/src/Popup/AssetHubMigration.tsx @@ -0,0 +1,81 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import React, { useCallback, useContext } from 'react'; + +import { ActionContext, Box, Button, ButtonArea, List } from '../components/index.js'; +import { useTranslation } from '../hooks/index.js'; +import { Header } from '../partials/index.js'; +import { styled } from '../styled.js'; + +interface Props { + className?: string; +} + +function AssetHubMigration ({ className }: Props): React.ReactElement { + const { t } = useTranslation(); + const onAction = useContext(ActionContext); + + const _onClick = useCallback( + (): void => { + window.localStorage.setItem('asset_hub_migration_read', 'ok'); + onAction(); + }, + [onAction] + ); + + return ( + <> +
+
+

{t('The Asset Hub migration has been completed. Please note the following important changes:')}

+ + +
  • {t('All balances have been migrated from the Relay Chain to Asset Hub')}
  • +
  • {t('All on-chain functionality has been moved to Asset Hub')}
  • +
  • {t('Asset Hub now holds user balances and provides general functionality')}
  • +
    +
    +

    {t('Do not teleport balances to the Relay Chain unless:')}

    + + +
  • {t('You are opening HRMP channels, or')}
  • +
  • {t('You are starting a Parachain')}
  • +
    +
    +

    {t('For all other operations, your balances are already on Asset Hub.')}

    +
    + + + + + ); +} + +export default styled(AssetHubMigration)` + p { + color: var(--subTextColor); + margin-bottom: 4px; + margin-top: 0; + line-height: 1.4; + } + + p.warning { + color: var(--errorColor); + font-weight: 600; + font-size: 1.1em; + margin-top: 6px; + margin-bottom: 2px; + text-transform: uppercase; + line-height: 1.3; + } + + article { + margin: 0.4rem 24px; + padding: 8px 20px; + } + + ul { + margin: 0; + } +`; diff --git a/packages/extension-ui/src/Popup/AuthManagement/AccountManagement.tsx b/packages/extension-ui/src/Popup/AuthManagement/AccountManagement.tsx new file mode 100644 index 0000000..0e6b781 --- /dev/null +++ b/packages/extension-ui/src/Popup/AuthManagement/AccountManagement.tsx @@ -0,0 +1,96 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import React, { useCallback, useContext, useEffect } from 'react'; +import { useParams } from 'react-router'; + +import { AccountContext, ActionContext, Button } from '../../components/index.js'; +import { useTranslation } from '../../hooks/index.js'; +import { getAuthList, updateAuthorization } from '../../messaging.js'; +import { AccountSelection, Header } from '../../partials/index.js'; +import { styled } from '../../styled.js'; + +interface Props { + className?: string; +} + +function AccountManagement ({ className }: Props): React.ReactElement { + const { url } = useParams<{url: string}>(); + const decodedUrl = decodeURIComponent(url); + const { selectedAccounts = [], setSelectedAccounts } = useContext(AccountContext); + const { t } = useTranslation(); + const onAction = useContext(ActionContext); + + useEffect(() => { + getAuthList() + .then(({ list }) => { + if (!list[decodedUrl]) { + return; + } + + setSelectedAccounts && setSelectedAccounts(list[decodedUrl].authorizedAccounts); + }) + .catch(console.error); + }, [setSelectedAccounts, decodedUrl]); + + const _onApprove = useCallback( + (): void => { + updateAuthorization(selectedAccounts, decodedUrl) + .then(() => onAction('../index.js')) + .catch(console.error); + }, + [onAction, selectedAccounts, decodedUrl] + ); + + return ( +
    +
    +
    + +
    + +
    +
    +
    + ); +} + +export default styled(AccountManagement)` + display: flex; + flex-direction: column; + height: 100%; + min-height: 550px; + + .content { + flex: 1; + display: flex; + flex-direction: column; + overflow: auto; + } + + .footer { + background: var(--background); + flex-shrink: 0; + } + + .acceptButton { + width: 90%; + margin: 0.5rem auto; + } +`; diff --git a/packages/extension-ui/src/Popup/AuthManagement/WebsiteEntry.tsx b/packages/extension-ui/src/Popup/AuthManagement/WebsiteEntry.tsx new file mode 100644 index 0000000..5d732e7 --- /dev/null +++ b/packages/extension-ui/src/Popup/AuthManagement/WebsiteEntry.tsx @@ -0,0 +1,70 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { AuthUrlInfo } from '@pezkuwi/extension-base/background/types'; + +import React, { useCallback } from 'react'; +import { Link } from 'react-router-dom'; + +import RemoveAuth from '../../components/RemoveAuth.js'; +import { useTranslation } from '../../hooks/index.js'; +import { styled } from '../../styled.js'; + +interface Props { + className?: string; + info: AuthUrlInfo; + removeAuth: (url: string) => void; + url: string; +} + +function WebsiteEntry ({ className = '', info: { authorizedAccounts, isAllowed }, removeAuth, url }: Props): React.ReactElement { + const { t } = useTranslation(); + + const _removeAuth = useCallback( + () => removeAuth(url), + [removeAuth, url] + ); + + return ( +
    + +
    + {url} +
    + { + authorizedAccounts?.length + ? t('{{total}} accounts', { + replace: { + total: authorizedAccounts.length + } + }) + : isAllowed + ? t('all accounts') + : t('no accounts') + } +
    + ); +} + +export default styled(WebsiteEntry)` + display: flex; + align-items: center; + margin-top: .2rem; + + .url{ + flex: 1; + } + + .connectedAccounts{ + margin-left: .5rem; + background-color: var(--primaryColor); + color: white; + cursor: pointer; + padding: 0 0.5rem; + border-radius: 4px; + text-decoration: none; + } +`; diff --git a/packages/extension-ui/src/Popup/AuthManagement/index.tsx b/packages/extension-ui/src/Popup/AuthManagement/index.tsx new file mode 100644 index 0000000..c4a6488 --- /dev/null +++ b/packages/extension-ui/src/Popup/AuthManagement/index.tsx @@ -0,0 +1,98 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { AuthUrlInfo, AuthUrls } from '@pezkuwi/extension-base/background/types'; + +import React, { useCallback, useEffect, useMemo, useState } from 'react'; + +import InputFilter from '../../components/InputFilter.js'; +import { useTranslation } from '../../hooks/index.js'; +import { getAuthList, removeAuthorization } from '../../messaging.js'; +import { Header } from '../../partials/index.js'; +import { styled } from '../../styled.js'; +import WebsiteEntry from './WebsiteEntry.js'; + +interface Props { + className?: string; +} + +function AuthManagement ({ className }: Props): React.ReactElement { + const { t } = useTranslation(); + const [authList, setAuthList] = useState(null); + const [filter, setFilter] = useState(''); + + useEffect(() => { + getAuthList() + .then(({ list }) => setAuthList(list)) + .catch((e) => console.error(e)); + }, []); + + const hasAuthList = useMemo( + () => !!authList && !!Object.keys(authList).length, + [authList] + ); + + const _onChangeFilter = useCallback((filter: string) => { + setFilter(filter); + }, []); + + const removeAuth = useCallback((url: string) => { + removeAuthorization(url) + .then(({ list }) => setAuthList(list)) + .catch(console.error); + }, []); + + return ( + <> +
    +
    + + { + !authList || !hasAuthList + ?
    {t('No website request yet!')}
    + : ( + <> +
    + {Object + .entries(authList) + .filter(([url]) => url.includes(filter)) + .map(([url, info]) => + + )} +
    + + ) + } +
    + + ); +} + +export default styled(AuthManagement)` + height: calc(100vh - 2px); + overflow-y: auto; + + .empty-list{ + text-align: center; + } + + .inputFilter{ + margin-bottom: 0.8rem; + padding: 0 !important; + } +`; diff --git a/packages/extension-ui/src/Popup/Authorize/Authorize.spec.tsx b/packages/extension-ui/src/Popup/Authorize/Authorize.spec.tsx new file mode 100644 index 0000000..09d8540 --- /dev/null +++ b/packages/extension-ui/src/Popup/Authorize/Authorize.spec.tsx @@ -0,0 +1,113 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import '@pezkuwi/extension-mocks/chrome'; + +import type { ReactWrapper } from 'enzyme'; +import type * as _ from '@pezkuwi/dev-test/globals.d.ts'; +import type { AccountJson, AuthorizeRequest } from '@pezkuwi/extension-base/background/types'; + +import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; +import enzyme from 'enzyme'; +import React from 'react'; + +import { AccountContext, AuthorizeReqContext, Warning } from '../../components/index.js'; +import { Header } from '../../partials/index.js'; +import { buildHierarchy } from '../../util/buildHierarchy.js'; +import Account from '../Accounts/Account.js'; +import Authorize from './index.js'; +import Request from './Request.js'; + +const { configure, mount } = enzyme; + +// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-call +configure({ adapter: new Adapter() }); + +const oneRequest = [{ id: '1', request: { origin: '???' }, url: 'http://polkadot.org' }]; + +const twoRequests = [ + ...oneRequest, + { id: '2', request: { origin: 'abc' }, url: 'http://polkadot.pl' } +]; + +const oneAccount = [ + { address: '5FjgD3Ns2UpnHJPVeRViMhCttuemaRXEqaD8V5z4vxcsUByA', name: 'A', type: 'sr25519' } +] as AccountJson[]; + +const twoAccountsOnehidden = [ + ...oneAccount, + { address: '5GYmFzQCuC5u3tQNiMZNbFGakrz3Jq31NmMg4D2QAkSoQ2g5', isHidden: true, name: 'B', type: 'sr25519' } +] as AccountJson[]; + +const threeAccountsOnehidden = [ + ...twoAccountsOnehidden, + { address: '5D2TPhGEy2FhznvzaNYW9AkuMBbg3cyRemnPsBvBY4ZhkZXA', name: 'BB', parentAddress: twoAccountsOnehidden[1].address, type: 'sr25519' } +] as AccountJson[]; + +describe('Authorize', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + const mountAuthorize = (authorizeRequests: AuthorizeRequest[] = [], accounts: AccountJson[] = oneAccount): ReactWrapper => mount( + + + + + ); + + it('render component', () => { + const wrapper = mountAuthorize(); + + expect(wrapper.find(Header).text()).toBe('Account connection request'); + expect(wrapper.find(Request).length).toBe(0); + }); + + it('render requests', () => { + const wrapper = mountAuthorize(oneRequest); + + expect(wrapper.find(Request).length).toBe(1); + expect(wrapper.find(Request).find('.warning-message').text()).toBe('An application, self-identifying as ??? is requesting access from http://polkadot.org'); + }); + + it('render more request but just one accept button', () => { + const wrapper = mountAuthorize(twoRequests); + + expect(wrapper.find(Request).length).toBe(2); + expect(wrapper.find(Warning).length).toBe(2); + expect(wrapper.find(Request).at(1).find('.warning-message').text()).toBe('An application, self-identifying as abc is requesting access from http://polkadot.pl'); + expect(wrapper.find('button.acceptButton').length).toBe(1); + }); + + it('render a warning and explication text when there is no account', () => { + const wrapper = mountAuthorize(oneRequest, []); + + expect(wrapper.find(Request).length).toBe(1); + expect(wrapper.find(Request).find('.warning-message').text()).toBe("You do not have any account. Please create an account and refresh the application's page."); + expect(wrapper.find('button.acceptButton').length).toBe(1); + }); + + it('show the right amount of accounts', () => { + const wrapper = mountAuthorize(oneRequest); + + expect(wrapper.find(Request).length).toBe(1); + expect(wrapper.find(Account).length).toBe(1); + expect(wrapper.find('button.acceptButton').length).toBe(1); + }); + + it('does not show the hidden accounts', () => { + const wrapper = mountAuthorize(oneRequest, twoAccountsOnehidden); + + expect(wrapper.find(Request).length).toBe(1); + expect(wrapper.find(Account).length).toBe(1); + }); + + it('shows the children of hidden accounts', () => { + const wrapper = mountAuthorize(oneRequest, threeAccountsOnehidden); + + expect(wrapper.find(Request).length).toBe(1); + expect(wrapper.find(Account).length).toBe(2); + }); +}); diff --git a/packages/extension-ui/src/Popup/Authorize/NoAccount.tsx b/packages/extension-ui/src/Popup/Authorize/NoAccount.tsx new file mode 100644 index 0000000..958a535 --- /dev/null +++ b/packages/extension-ui/src/Popup/Authorize/NoAccount.tsx @@ -0,0 +1,47 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import { t } from 'i18next'; +import React, { useCallback } from 'react'; +import { Trans } from 'react-i18next'; + +import { Button, Warning } from '../../components/index.js'; +import { rejectAuthRequest } from '../../messaging.js'; +import { styled } from '../../styled.js'; + +interface Props { + authId: string; + className?: string; +} + +function NoAccount ({ authId, className }: Props): React.ReactElement { + const _onClick = useCallback(() => { + rejectAuthRequest(authId).catch(console.error); + }, [authId] + ); + + return ( +
    + + You do not have any account. Please create an account and refresh the application's page. + + +
    + ); +} + +export default styled(NoAccount)` + .acceptButton { + width: 90%; + margin: 25px auto 0; + } + + .warningMargin { + margin: 1rem 24px 0 1.45rem; + } +`; diff --git a/packages/extension-ui/src/Popup/Authorize/Request.tsx b/packages/extension-ui/src/Popup/Authorize/Request.tsx new file mode 100644 index 0000000..2271abf --- /dev/null +++ b/packages/extension-ui/src/Popup/Authorize/Request.tsx @@ -0,0 +1,138 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { RequestAuthorizeTab } from '@pezkuwi/extension-base/background/types'; + +import React, { useCallback, useContext, useEffect, useState } from 'react'; + +import { AccountContext, ActionContext, Button } from '../../components/index.js'; +import { useTranslation } from '../../hooks/index.js'; +import { approveAuthRequest, cancelAuthRequest, rejectAuthRequest } from '../../messaging.js'; +import { AccountSelection } from '../../partials/index.js'; +import { styled } from '../../styled.js'; +import NoAccount from './NoAccount.js'; + +interface Props { + authId: string; + className?: string; + request: RequestAuthorizeTab; + url: string; +} + +function Request ({ authId, className, request: { origin }, url }: Props): React.ReactElement { + const { accounts, selectedAccounts = [], setSelectedAccounts } = useContext(AccountContext); + const { t } = useTranslation(); + const onAction = useContext(ActionContext); + const [dontAskAgain, setDontAskAgain] = useState(false); + + useEffect(() => { + const defaultAccountSelection = accounts + .filter(({ isDefaultAuthSelected }) => !!isDefaultAuthSelected) + .map(({ address }) => address); + + setSelectedAccounts && setSelectedAccounts(defaultAccountSelection); + }, [accounts, setSelectedAccounts]); + + const _onApprove = useCallback( + (): void => { + approveAuthRequest(authId, selectedAccounts) + .then(() => onAction()) + .catch((error: Error) => console.error(error)); + }, + [authId, onAction, selectedAccounts] + ); + + const _onReject = useCallback( + (): void => { + const rejectFunction = dontAskAgain ? rejectAuthRequest : cancelAuthRequest; + + rejectFunction(authId) + .then(() => onAction()) + .catch((error: Error) => console.error(error)); + }, + [authId, onAction, dontAskAgain] + ); + + const _onToggleDontAskAgain = useCallback( + (): void => { + setDontAskAgain((prev) => !prev); + }, + [] + ); + + if (!accounts.length) { + return ; + } + + return ( +
    + +
    +
    + + +
    +
    + + +
    +
    +
    + + ); +} + +export default styled(Request)` + display: flex; + flex-direction: column; + flex: 1; + overflow-y: auto; + + .footer { + padding: 1rem 1rem 0rem 1rem; + background: var(--background); + } + + .buttonContainer { + display: flex; + justify-content: space-between; + width: 100%; + margin-bottom: 0.5rem; + } + + .acceptButton, .rejectButton { + width: 48%; + height: 40px; + } + + .dontAskAgainContainer { + display: flex; + align-items: center; + justify-content: center; + text-align: center; + + input { + margin-right: 0.5rem; + } + } +`; diff --git a/packages/extension-ui/src/Popup/Authorize/index.tsx b/packages/extension-ui/src/Popup/Authorize/index.tsx new file mode 100644 index 0000000..5864aab --- /dev/null +++ b/packages/extension-ui/src/Popup/Authorize/index.tsx @@ -0,0 +1,124 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import React, { useCallback, useContext, useEffect, useRef, useState } from 'react'; + +import { AuthorizeReqContext } from '../../components/index.js'; +import { useTranslation } from '../../hooks/index.js'; +import { Header } from '../../partials/index.js'; +import { styled } from '../../styled.js'; +import Request from './Request.js'; + +interface Props { + className?: string; +} + +function Authorize ({ className = '' }: Props): React.ReactElement { + const { t } = useTranslation(); + const requests = useContext(AuthorizeReqContext); + const [currentIndex, setCurrentIndex] = useState(0); + const currentIndexRef = useRef(currentIndex); + + useEffect(() => { + if (requests.length <= currentIndexRef.current) { + setCurrentIndex((prevIndex) => Math.max(prevIndex - 1, 0)); + } + }, [requests.length]); + + const handleNext = useCallback(() => { + setCurrentIndex((prevIndex) => { + const newIndex = Math.min(prevIndex + 1, requests.length - 1); + + currentIndexRef.current = newIndex; + + return newIndex; + }); + }, [requests.length]); + + const handlePrevious = useCallback(() => { + setCurrentIndex((prevIndex) => { + const newIndex = Math.max(prevIndex - 1, 0); + + currentIndexRef.current = newIndex; + + return newIndex; + }); + }, []); + + return ( +
    +
    + {requests.length > 1 && ( +
    + + {`${currentIndex + 1} / ${requests.length}`} + +
    + )} + {requests.length > 0 && requests[currentIndex] && ( + + )} +
    + ); +} + +export default styled(Authorize)` + display: flex; + flex-direction: column; + height: 100%; + overflow: auto; + + && { + padding: 0; + } + + .request { + padding: 0 24px; + flex: 1; + display: flex; + flex-direction: column; + overflow: auto; + min-height: 400px; + } + + .pagination { + display: flex; + justify-content: space-between; + background: var(--background); + + button { + background: none; + border: none; + color: var(--textColor); + cursor: pointer; + padding: 0.5rem 1rem; + + &.hidden { + visibility: hidden; + } + } + + span { + align-self: center; + } + } +`; diff --git a/packages/extension-ui/src/Popup/CreateAccount/CreateAccount.spec.tsx b/packages/extension-ui/src/Popup/CreateAccount/CreateAccount.spec.tsx new file mode 100644 index 0000000..c764810 --- /dev/null +++ b/packages/extension-ui/src/Popup/CreateAccount/CreateAccount.spec.tsx @@ -0,0 +1,124 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import '@pezkuwi/extension-mocks/chrome'; + +import type { ReactWrapper } from 'enzyme'; +import type * as _ from '@pezkuwi/dev-test/globals.d.ts'; + +import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; +import enzyme from 'enzyme'; +import React from 'react'; +import { act } from 'react-dom/test-utils'; + +import { ActionContext, ActionText, Button } from '../../components/index.js'; +import * as messaging from '../../messaging.js'; +import { Header } from '../../partials/index.js'; +import { flushAllPromises } from '../../testHelpers.js'; +import CreateAccount from './index.js'; + +const { configure, mount } = enzyme; + +// // NOTE Required for spyOn when using @swc/jest +// // https://github.com/swc-project/swc/issues/3843 +// jest.mock('../../messaging', (): Record => ({ +// __esModule: true, +// ...jest.requireActual('../../messaging') +// })); + +// For this file, there are a lot of them +/* eslint-disable @typescript-eslint/no-unsafe-argument */ + +// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-call +configure({ adapter: new Adapter() }); + +describe('Create Account', () => { + let wrapper: ReactWrapper; + let onActionStub: ReturnType; + const exampleAccount = { + address: 'HjoBp62cvsWDA3vtNMWxz6c9q13ReEHi9UGHK7JbZweH5g5', + seed: 'horse battery staple correct' + }; + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + const mountComponent = (): ReactWrapper => mount( + + + + ); + + const check = (input: ReactWrapper): unknown => input.simulate('change', { target: { checked: true } }); + + const type = async (input: ReactWrapper, value: string): Promise => { + input.simulate('change', { target: { value } }); + await act(flushAllPromises); + wrapper.update(); + }; + + const enterName = (name: string): Promise => type(wrapper.find('input').first(), name); + const password = (password: string) => (): Promise => type(wrapper.find('input[type="password"]').first(), password); + const repeat = (password: string) => (): Promise => type(wrapper.find('input[type="password"]').last(), password); + + beforeEach(async () => { + onActionStub = jest.fn(); + jest.spyOn(messaging, 'createSeed').mockImplementation(() => Promise.resolve(exampleAccount)); + jest.spyOn(messaging, 'createAccountSuri').mockImplementation(() => Promise.resolve(true)); + wrapper = mountComponent(); + await act(flushAllPromises); + wrapper.update(); + }); + + describe('Phase 1', () => { + it('shows seed phrase in a span inside a div', () => { + expect(wrapper.find('.seedBox span').text()).toBe(exampleAccount.seed); + }); + + it('next step button is disabled when checkbox is not checked', () => { + expect(wrapper.find(Button).prop('isDisabled')).toBe(true); + }); + + it('action text is "Cancel"', () => { + expect(wrapper.find(Header).find(ActionText).text()).toBe('Cancel'); + }); + + it('clicking "Cancel" redirects to main screen', () => { + wrapper.find(Header).find(ActionText).simulate('click'); + expect(onActionStub).toHaveBeenCalledWith('/'); + }); + + it('checking the checkbox enables the Next button', () => { + check(wrapper.find('input[type="checkbox"]')); + + expect(wrapper.find(Button).prop('isDisabled')).toBe(false); + }); + + it('clicking on Next activates phase 2', () => { + check(wrapper.find('input[type="checkbox"]')); + wrapper.find('button').simulate('click'); + expect(wrapper.find(Header).text()).toBe('Create an account2/2Cancel'); + }); + }); + + describe('Phase 2', () => { + beforeEach(async () => { + check(wrapper.find('input[type="checkbox"]')); + wrapper.find('button').simulate('click'); + await act(flushAllPromises); + wrapper.update(); + }); + + it('saves account with provided network, name and password', async () => { + const kusamaGenesis = '0xb0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe'; + + wrapper.find('select').simulate('change', { target: { value: kusamaGenesis } }); + await act(flushAllPromises); + wrapper.update(); + + await enterName('abc').then(password('abcdef')).then(repeat('abcdef')); + wrapper.find('[data-button-action="add new root"] button').simulate('click'); + await act(flushAllPromises); + + expect(messaging.createAccountSuri).toHaveBeenCalledWith('abc', 'abcdef', exampleAccount.seed, 'sr25519', kusamaGenesis); + expect(onActionStub).toHaveBeenCalledWith('/'); + }); + }); +}); diff --git a/packages/extension-ui/src/Popup/CreateAccount/Mnemonic.tsx b/packages/extension-ui/src/Popup/CreateAccount/Mnemonic.tsx new file mode 100644 index 0000000..de8d2ec --- /dev/null +++ b/packages/extension-ui/src/Popup/CreateAccount/Mnemonic.tsx @@ -0,0 +1,50 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import React, { useCallback, useState } from 'react'; + +import { ButtonArea, Checkbox, MnemonicSeed, NextStepButton, VerticalSpace, Warning } from '../../components/index.js'; +import { useToast, useTranslation } from '../../hooks/index.js'; + +interface Props { + onNextStep: () => void; + seed: string; +} + +function Mnemonic ({ onNextStep, seed }: Props): React.ReactElement { + const { t } = useTranslation(); + const [isMnemonicSaved, setIsMnemonicSaved] = useState(false); + const { show } = useToast(); + + const _onCopy = useCallback((): void => { + show(t('Copied')); + }, [show, t]); + + return ( + <> + + + {t("Please write down your wallet's mnemonic seed and keep it in a safe place. The mnemonic can be used to restore your wallet. Keep it carefully to not lose your assets.")} + + + + + + {t('Next step')} + + + + ); +} + +export default React.memo(Mnemonic); diff --git a/packages/extension-ui/src/Popup/CreateAccount/index.tsx b/packages/extension-ui/src/Popup/CreateAccount/index.tsx new file mode 100644 index 0000000..0b863a6 --- /dev/null +++ b/packages/extension-ui/src/Popup/CreateAccount/index.tsx @@ -0,0 +1,141 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { HexString } from '@pezkuwi/util/types'; + +import React, { useCallback, useContext, useEffect, useState } from 'react'; + +import AccountNamePasswordCreation from '../../components/AccountNamePasswordCreation.js'; +import { ActionContext, Address, Dropdown, Loading } from '../../components/index.js'; +import { useGenesisHashOptions, useMetadata, useTranslation } from '../../hooks/index.js'; +import { createAccountSuri, createSeed, validateSeed } from '../../messaging.js'; +import { HeaderWithSteps } from '../../partials/index.js'; +import { styled } from '../../styled.js'; +import { DEFAULT_TYPE } from '../../util/defaultType.js'; +import Mnemonic from './Mnemonic.js'; + +interface Props { + className?: string; +} + +function CreateAccount ({ className }: Props): React.ReactElement { + const { t } = useTranslation(); + const onAction = useContext(ActionContext); + const [isBusy, setIsBusy] = useState(false); + const [step, setStep] = useState(1); + const [address, setAddress] = useState(null); + const [seed, setSeed] = useState(null); + const [type, setType] = useState(DEFAULT_TYPE); + const [name, setName] = useState(''); + const options = useGenesisHashOptions(); + const [genesisHash, setGenesis] = useState(null); + const chain = useMetadata(genesisHash, true); + + useEffect((): void => { + createSeed(undefined) + .then(({ address, seed }): void => { + setAddress(address); + setSeed(seed); + }) + .catch(console.error); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect((): void => { + if (seed) { + const type = chain && chain.definition.chainType === 'ethereum' + ? 'ethereum' + : DEFAULT_TYPE; + + setType(type); + validateSeed(seed, type) + .then(({ address }) => setAddress(address)) + .catch(console.error); + } + }, [seed, chain]); + + const _onCreate = useCallback( + (name: string, password: string): void => { + // this should always be the case + if (name && password && seed) { + setIsBusy(true); + + createAccountSuri(name, password, seed, type, genesisHash) + .then(() => onAction('/')) + .catch((error: Error): void => { + setIsBusy(false); + console.error(error); + }); + } + }, + [genesisHash, onAction, seed, type] + ); + + const _onNextStep = useCallback( + () => setStep((step) => step + 1), + [] + ); + + const _onPreviousStep = useCallback( + () => setStep((step) => step - 1), + [] + ); + + const _onChangeNetwork = useCallback( + (newGenesisHash: HexString) => setGenesis(newGenesisHash), + [] + ); + + return ( + <> + + +
    +
    +
    + {seed && ( + step === 1 + ? ( + + ) + : ( + <> + + + + ) + )} +
    + + ); +} + +export default styled(CreateAccount)` + margin-bottom: 16px; + + label::after { + right: 36px; + } +`; diff --git a/packages/extension-ui/src/Popup/Derive/AddressDropdown.tsx b/packages/extension-ui/src/Popup/Derive/AddressDropdown.tsx new file mode 100644 index 0000000..8109206 --- /dev/null +++ b/packages/extension-ui/src/Popup/Derive/AddressDropdown.tsx @@ -0,0 +1,106 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import React, { useCallback, useRef, useState } from 'react'; + +import arrow from '../../assets/arrow-down.svg'; +import { Address } from '../../components/index.js'; +import { useOutsideClick } from '../../hooks/index.js'; +import { styled } from '../../styled.js'; + +interface Props { + allAddresses: [string, string | null][]; + className?: string; + onSelect: (address: string) => void; + selectedAddress: string; + selectedGenesis: string | null; +} + +function AddressDropdown ({ allAddresses, className, onSelect, selectedAddress, selectedGenesis }: Props): React.ReactElement { + const [isDropdownVisible, setDropdownVisible] = useState(false); + const ref = useRef(null); + + const _hideDropdown = useCallback(() => setDropdownVisible(false), []); + const _toggleDropdown = useCallback(() => setDropdownVisible(!isDropdownVisible), [isDropdownVisible]); + const _selectParent = useCallback((newParent: string) => () => onSelect(newParent), [onSelect]); + + useOutsideClick([ref], _hideDropdown); + + return ( +
    +
    +
    +
    +
    + {allAddresses.map(([address, genesisHash]) => ( +
    +
    +
    + ))} +
    +
    + ); +} + +export default styled(AddressDropdown)` + margin-bottom: 16px; + cursor: pointer; + + & > div:first-child > .address::after { + content: ''; + position: absolute; + top: 66%; + transform: translateY(-50%); + right: 11px; + width: 30px; + height: 30px; + background: url(${arrow}) center no-repeat; + background-color: var(--inputBackground); + pointer-events: none; + border-radius: 4px; + border: 1px solid var(--boxBorderColor); + } + + .address .copyIcon { + visibility: hidden; + } + + .dropdown { + position: absolute; + visibility: hidden; + width: 510px; + z-index: 100; + background: var(--bodyColor); + max-height: 0; + overflow: auto; + padding: 5px; + border: 1px solid var(--boxBorderColor); + box-sizing: border-box; + border-radius: 4px; + margin-top: -8px; + + &.visible{ + visibility: visible; + max-height: 200px; + } + + & > div { + cursor: pointer; + } + } +`; diff --git a/packages/extension-ui/src/Popup/Derive/DerivationPath.tsx b/packages/extension-ui/src/Popup/Derive/DerivationPath.tsx new file mode 100644 index 0000000..0b4fa4a --- /dev/null +++ b/packages/extension-ui/src/Popup/Derive/DerivationPath.tsx @@ -0,0 +1,110 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import { faLock, faLockOpen } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import React, { useCallback, useEffect, useState } from 'react'; + +import { Button, InputWithLabel } from '../../components/index.js'; +import { useTranslation } from '../../hooks/index.js'; +import { styled } from '../../styled.js'; + +interface Props { + className?: string; + defaultPath: string; + isError: boolean; + onChange: (suri: string) => void; + parentAddress: string; + parentPassword: string; + withSoftPath: boolean; +} + +function DerivationPath ({ className, defaultPath, isError, onChange, withSoftPath }: Props): React.ReactElement { + const { t } = useTranslation(); + const [path, setPath] = useState(defaultPath); + const [isDisabled, setIsDisabled] = useState(true); + + useEffect(() => { + setPath(defaultPath); + }, [defaultPath]); + + const _onExpand = useCallback(() => setIsDisabled(!isDisabled), [isDisabled]); + + const _onChange = useCallback((newPath: string): void => { + setPath(newPath); + onChange(newPath); + }, [onChange]); + + return ( +
    +
    +
    + +
    + +
    +
    + ); +} + +export default React.memo(styled(DerivationPath)` + > .container { + display: flex; + flex-direction: row; + } + + .lockButton { + background: none; + height: 14px; + margin: 36px 2px 0 10px; + padding: 3px; + width: 11px; + + &:not(:disabled):hover { + background: none; + } + + &:active, &:focus { + outline: none; + } + + &::-moz-focus-inner { + border: 0; + } + } + + .lockIcon { + color: var(--iconNeutralColor) + } + + .pathInput { + width: 100%; + + &.locked input { + opacity: 50%; + } + } +`); diff --git a/packages/extension-ui/src/Popup/Derive/Derive.spec.tsx b/packages/extension-ui/src/Popup/Derive/Derive.spec.tsx new file mode 100644 index 0000000..fa84178 --- /dev/null +++ b/packages/extension-ui/src/Popup/Derive/Derive.spec.tsx @@ -0,0 +1,331 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import '@pezkuwi/extension-mocks/chrome'; + +import type { ReactWrapper } from 'enzyme'; +import type * as _ from '@pezkuwi/dev-test/globals.d.ts'; +import type { AccountJson, ResponseDeriveValidate } from '@pezkuwi/extension-base/background/types'; + +import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; +import enzyme from 'enzyme'; +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { MemoryRouter, Route } from 'react-router'; + +import { AccountContext, ActionContext } from '../../components/index.js'; +import * as messaging from '../../messaging.js'; +import { flushAllPromises } from '../../testHelpers.js'; +import { buildHierarchy } from '../../util/buildHierarchy.js'; +import AddressDropdown from './AddressDropdown.js'; +import Derive from './index.js'; + +const { configure, mount } = enzyme; + +// // NOTE Required for spyOn when using @swc/jest +// // https://github.com/swc-project/swc/issues/3843 +// jest.mock('../../messaging', (): Record => ({ +// __esModule: true, +// ...jest.requireActual('../../messaging') +// })); + +// For this file, there are a lot of them +/* eslint-disable @typescript-eslint/no-unsafe-argument */ + +// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-call +configure({ adapter: new Adapter() }); + +const parentPassword = 'pass'; +const westendGenesis = '0xe143f23803ac50e8f6f8e62695d1ce9e4e1d68aa36c1cd2cfd15340213f3423e'; +const defaultDerivation = '//0'; +const derivedAddress = '5GYQRJj3NUznYDzCduENRcocMsyxmb6tjb5xW87ZMErBe9R7'; + +const accounts = [ + { address: '5FjgD3Ns2UpnHJPVeRViMhCttuemaRXEqaD8V5z4vxcsUByA', name: 'A', type: 'sr25519' }, + { address: '5GYmFzQCuC5u3tQNiMZNbFGakrz3Jq31NmMg4D2QAkSoQ2g5', genesisHash: westendGenesis, name: 'B', type: 'sr25519' }, + { address: '5D2TPhGEy2FhznvzaNYW9AkuMBbg3cyRemnPsBvBY4ZhkZXA', name: 'BB', parentAddress: '5GYmFzQCuC5u3tQNiMZNbFGakrz3Jq31NmMg4D2QAkSoQ2g5', type: 'sr25519' }, + { address: '5GhGENSJBWQZ8d8mARKgqEkiAxiW3hHeznQDW2iG4XzNieb6', isExternal: true, name: 'C', type: 'sr25519' }, + { address: '0xd5D81CD4236a43F48A983fc5B895975c511f634D', name: 'Ethereum', type: 'ethereum' }, + { address: '5EeaoDj4VDk8V6yQngKBaCD5MpJUCHrhYjVhBjgMHXoYon1s', isExternal: false, name: 'D', type: 'ed25519' }, + { address: '5HRKYp5anSNGtqC7cq9ftiaq4y8Mk7uHk7keaXUrQwZqDWLJ', name: 'DD', parentAddress: '5EeaoDj4VDk8V6yQngKBaCD5MpJUCHrhYjVhBjgMHXoYon1s', type: 'ed25519' } +] as AccountJson[]; + +describe('Derive', () => { + const mountComponent = async (locked = false, account = 1): Promise<{ + wrapper: ReactWrapper; + onActionStub: ReturnType; + }> => { + const onActionStub = jest.fn(); + + const wrapper = mount( + + + + + + + + + + ); + + await act(flushAllPromises); + + return { onActionStub, wrapper }; + }; + + let wrapper: ReactWrapper; + let onActionStub: ReturnType; + + const type = async (input: ReactWrapper, value: string): Promise => { + input.simulate('change', { target: { value } }); + await act(flushAllPromises); + input.update(); + }; + + const enterName = (name: string): Promise => type(wrapper.find('input').first(), name); + const password = (password: string) => (): Promise => type(wrapper.find('input[type="password"]').first(), password); + const repeat = (password: string) => (): Promise => type(wrapper.find('input[type="password"]').last(), password); + + describe('Parent selection screen', () => { + beforeEach(async () => { + const mountedComponent = await mountComponent(); + + wrapper = mountedComponent.wrapper; + onActionStub = mountedComponent.onActionStub; + }); + + // eslint-disable-next-line @typescript-eslint/require-await + jest.spyOn(messaging, 'validateAccount').mockImplementation(async (_, pass) => pass === parentPassword); + // silencing the following expected console.error + console.error = jest.fn(); + // eslint-disable-next-line @typescript-eslint/require-await + jest.spyOn(messaging, 'validateDerivationPath').mockImplementation(async (_, path) => { + if (path === '//') { + throw new Error('wrong suri'); + } + + return { address: derivedAddress, suri: defaultDerivation } as ResponseDeriveValidate; + }); + + it('Button is disabled and password field visible, path field is hidden', () => { + const button = wrapper.find('[data-button-action="create derived account"] button'); + + expect(button.exists()).toBe(true); + expect(button.prop('disabled')).toBe(true); + expect(wrapper.find('.pathInput').exists()).toBe(false); + }); + + it('Password field is visible and not in error state', () => { + const passwordField = wrapper.find('[data-input-password]').first(); + + expect(passwordField.exists()).toBe(true); + expect(passwordField.prop('isError')).toBe(false); + }); + + it('No error is visible when first loading the page', () => { + expect(wrapper.find('Warning')).toHaveLength(0); + }); + + it('An error is visible, input higlighted and the button disabled when password is incorrect', async () => { + await type(wrapper.find('input[type="password"]'), 'wrong_pass'); + wrapper.find('[data-button-action="create derived account"] button').simulate('click'); + await act(flushAllPromises); + wrapper.update(); + + const button = wrapper.find('[data-button-action="create derived account"] button'); + + expect(button.prop('disabled')).toBe(true); + expect(wrapper.find('[data-input-password]').first().prop('isError')).toBe(true); + expect(wrapper.find('.warning-message')).toHaveLength(1); + expect(wrapper.find('.warning-message').first().text()).toEqual('Wrong password'); + }); + + it('The error disappears when typing a new password and "Create derived account" is enabled', async () => { + await type(wrapper.find('input[type="password"]'), 'wrong_pass'); + wrapper.find('[data-button-action="create derived account"] button').simulate('click'); + await act(flushAllPromises); + wrapper.update(); + + await type(wrapper.find('input[type="password"]'), 'new_attempt'); + + const button = wrapper.find('[data-button-action="create derived account"] button'); + + expect(button.prop('disabled')).toBe(false); + expect(wrapper.find('[data-input-password]').first().prop('isError')).toBe(false); + expect(wrapper.find('.warning-message')).toHaveLength(0); + }); + + it('Button is enabled when password is set', async () => { + await type(wrapper.find('input[type="password"]'), parentPassword); + + const button = wrapper.find('[data-button-action="create derived account"] button'); + + expect(button.prop('disabled')).toBe(false); + expect(wrapper.find('.warning-message')).toHaveLength(0); + }); + + it('Derivation path gets visible, is set and locked', async () => { + await type(wrapper.find('input[type="password"]'), 'wrong_pass'); + + expect(wrapper.find('.pathInput.locked input').prop('disabled')).toBe(true); + expect(wrapper.find('.pathInput.locked input').prop('value')).toBe('//1'); + }); + + it('Derivation path can be unlocked', async () => { + await type(wrapper.find('input[type="password"]'), 'wrong_pass'); + wrapper.find('FontAwesomeIcon.lockIcon').simulate('click'); + await act(flushAllPromises); + wrapper.update(); + + expect(wrapper.find('.pathInput').exists()).toBe(true); + expect(wrapper.find('.pathInput input').prop('disabled')).toBe(false); + }); + + it('Derivation path placeholder contains //hard/soft', async () => { + await type(wrapper.find('input[type="password"]'), parentPassword); + const pathInput = wrapper.find('[data-input-suri] input'); + + expect(pathInput.first().prop('placeholder')).toEqual('//hard/soft'); + }); + + it('An error is visible and the button is disabled when suri is incorrect', async () => { + await type(wrapper.find('input[type="password"]'), parentPassword); + await type(wrapper.find('[data-input-suri] input'), '//'); + wrapper.find('[data-button-action="create derived account"] button').simulate('click'); + await act(flushAllPromises); + wrapper.update(); + + const button = wrapper.find('[data-button-action="create derived account"] button'); + + expect(button.prop('disabled')).toBe(true); + expect(wrapper.find('.warning-message')).toHaveLength(1); + expect(wrapper.find('.warning-message').first().text()).toEqual('Invalid derivation path'); + }); + + it('An error is visible and the button is disabled when suri contains `///`', async () => { + await type(wrapper.find('input[type="password"]'), parentPassword); + await type(wrapper.find('[data-input-suri] input'), '///'); + + const button = wrapper.find('[data-button-action="create derived account"] button'); + + expect(button.prop('disabled')).toBe(true); + expect(wrapper.find('.warning-message')).toHaveLength(1); + // eslint-disable-next-line quotes + expect(wrapper.find('.warning-message').first().text()).toEqual("`///password` not supported for derivation"); + }); + + it('No error is shown when suri contains soft derivation `/` with sr25519', async () => { + await type(wrapper.find('input[type="password"]'), parentPassword); + await type(wrapper.find('[data-input-suri] input'), '//somehard/soft'); + + const button = wrapper.find('[data-button-action="create derived account"] button'); + + expect(button.prop('disabled')).toBe(false); + expect(wrapper.find('.warning-message')).toHaveLength(0); + }); + + it('The error disappears and "Create derived account" is enabled when typing a new suri', async () => { + await type(wrapper.find('input[type="password"]'), parentPassword); + await type(wrapper.find('[data-input-suri] input'), '//'); + wrapper.find('[data-button-action="create derived account"] button').simulate('click'); + await act(flushAllPromises); + wrapper.update(); + await type(wrapper.find('[data-input-suri] input'), 'new'); + + const button = wrapper.find('[data-button-action="create derived account"] button'); + + expect(button.prop('disabled')).toBe(false); + expect(wrapper.find('Warning')).toHaveLength(0); + }); + + it('takes selected address from URL as parent account', () => { + expect(wrapper.find('[data-field="name"]').first().text()).toBe('B'); + }); + + it('selects internal root accounts as other options, no external and no Ethereum account', () => { + const options = wrapper.find('[data-parent-option] [data-field="name"]').map((el) => el.text()); + + expect(options).toEqual(['A', 'B', 'D', 'Ethereum']); + }); + + it('redirects to derive from next account when other option is selected', () => { + wrapper.find('[data-parent-option]').first().simulate('click'); + + expect(onActionStub).toHaveBeenCalledWith(`/account/derive/${accounts[0].address}`); + }); + }); + + describe('Locked parent selection', () => { + beforeAll(async () => { + const mountedComponent = (await mountComponent(true)); + + wrapper = mountedComponent.wrapper; + onActionStub = mountedComponent.onActionStub; + }); + + it('address dropdown does not exist', () => { + expect(wrapper.exists(AddressDropdown)).toBe(false); + }); + + it('parent is taken from URL', () => { + expect(wrapper.find('[data-field="name"]').first().text()).toBe('B'); + }); + + describe('Second phase', () => { + it('correctly creates the derived account', async () => { + const newAccount = { + name: 'newName', + password: 'somePassword' + }; + const deriveMock = jest.spyOn(messaging, 'deriveAccount'); + + await type(wrapper.find('input[type="password"]'), parentPassword); + wrapper.find('[data-button-action="create derived account"] button').simulate('click'); + await act(flushAllPromises); + wrapper.update(); + await enterName(newAccount.name).then(password(newAccount.password)).then(repeat(newAccount.password)); + wrapper.find('[data-button-action="add new root"] button').simulate('click'); + await act(flushAllPromises); + wrapper.update(); + + expect(deriveMock).toHaveBeenCalledWith(accounts[1].address, defaultDerivation, parentPassword, newAccount.name, newAccount.password, westendGenesis); + expect(onActionStub).toHaveBeenCalledWith('/'); + }); + }); + }); + + describe('Ed25519 Parent', () => { + beforeEach(async () => { + const mountedComponent = await mountComponent(false, 5); + + wrapper = mountedComponent.wrapper; + onActionStub = mountedComponent.onActionStub; + await type(wrapper.find('input[type="password"]'), parentPassword); + }); + + it('Derivation path placeholder only contains //hard', () => { + const pathInput = wrapper.find('[data-input-suri] input'); + + expect(pathInput.first().prop('placeholder')).toEqual('//hard'); + }); + + it('An error is shown when suri contains soft derivation `/` with ed25519', async () => { + const pathInput = wrapper.find('[data-input-suri] input'); + + await type(pathInput, '//somehard/soft'); + + const button = wrapper.find('[data-button-action="create derived account"] button'); + + expect(button.prop('disabled')).toBe(true); + expect(wrapper.find('[data-input-suri]').first().prop('isError')).toBe(true); + expect(wrapper.find('.warning-message')).toHaveLength(1); + expect(wrapper.find('.warning-message').first().text()).toEqual('Soft derivation is only allowed for sr25519 accounts'); + }); + }); +}); diff --git a/packages/extension-ui/src/Popup/Derive/SelectParent.tsx b/packages/extension-ui/src/Popup/Derive/SelectParent.tsx new file mode 100644 index 0000000..6c4615d --- /dev/null +++ b/packages/extension-ui/src/Popup/Derive/SelectParent.tsx @@ -0,0 +1,195 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; + +import { canDerive } from '@pezkuwi/extension-base/utils'; + +import { AccountContext, ActionContext, Address, ButtonArea, InputWithLabel, Label, NextStepButton, VerticalSpace, Warning } from '../../components/index.js'; +import { useTranslation } from '../../hooks/index.js'; +import { validateAccount, validateDerivationPath } from '../../messaging.js'; +import { nextDerivationPath } from '../../util/nextDerivationPath.js'; +import AddressDropdown from './AddressDropdown.js'; +import DerivationPath from './DerivationPath.js'; + +interface Props { + className?: string; + isLocked?: boolean; + parentAddress: string; + parentGenesis: string | null; + onDerivationConfirmed: (derivation: { account: { address: string; suri: string }; parentPassword: string }) => void; +} + +// match any single slash +const singleSlashRegex = /([^/]|^)\/([^/]|$)/; + +export default function SelectParent ({ className, isLocked, onDerivationConfirmed, parentAddress, parentGenesis }: Props): React.ReactElement { + const { t } = useTranslation(); + const onAction = useContext(ActionContext); + const [isBusy, setIsBusy] = useState(false); + const { accounts, hierarchy } = useContext(AccountContext); + const defaultPath = useMemo(() => nextDerivationPath(accounts, parentAddress), [accounts, parentAddress]); + const [suriPath, setSuriPath] = useState(defaultPath); + const [parentPassword, setParentPassword] = useState(''); + const [isProperParentPassword, setIsProperParentPassword] = useState(false); + const [pathError, setPathError] = useState(''); + const passwordInputRef = useRef(null); + const allowSoftDerivation = useMemo(() => { + const parent = accounts.find(({ address }) => address === parentAddress); + + return parent?.type === 'sr25519'; + }, [accounts, parentAddress]); + + // reset the password field if the parent address changes + useEffect(() => { + setParentPassword(''); + }, [parentAddress]); + + useEffect(() => { + // forbid the use of password since Keyring ignores it + if (suriPath?.includes('///')) { + setPathError(t('`///password` not supported for derivation')); + } + + if (!allowSoftDerivation && suriPath && singleSlashRegex.test(suriPath)) { + setPathError(t('Soft derivation is only allowed for sr25519 accounts')); + } + }, [allowSoftDerivation, suriPath, t]); + + const allAddresses = useMemo( + () => hierarchy + .filter(({ isExternal }) => !isExternal) + .filter(({ type }) => canDerive(type)) + .map(({ address, genesisHash }): [string, string | null] => [address, genesisHash || null]), + [hierarchy] + ); + + const _onParentPasswordEnter = useCallback( + (parentPassword: string): void => { + setParentPassword(parentPassword); + setIsProperParentPassword(!!parentPassword); + }, + [] + ); + + const _onSuriPathChange = useCallback( + (path: string): void => { + setSuriPath(path); + setPathError(''); + }, + [] + ); + + const _onParentChange = useCallback( + (address: string) => onAction(`/account/derive/${address}`), + [onAction] + ); + + const _onSubmit = useCallback( + async (): Promise => { + if (suriPath && parentAddress && parentPassword) { + setIsBusy(true); + + const isUnlockable = await validateAccount(parentAddress, parentPassword); + + if (isUnlockable) { + try { + const account = await validateDerivationPath(parentAddress, suriPath, parentPassword); + + onDerivationConfirmed({ account, parentPassword }); + } catch (error) { + setIsBusy(false); + setPathError(t('Invalid derivation path')); + console.error(error); + } + } else { + setIsBusy(false); + setIsProperParentPassword(false); + } + } + }, + [parentAddress, parentPassword, onDerivationConfirmed, suriPath, t] + ); + + useEffect(() => { + setParentPassword(''); + setIsProperParentPassword(false); + + passwordInputRef.current?.querySelector('input')?.focus(); + }, [_onParentPasswordEnter]); + + return ( + <> +
    + {isLocked + ? ( +
    + ) + : ( + + ) + } +
    + + {!!parentPassword && !isProperParentPassword && ( + + {t('Wrong password')} + + )} +
    + {isProperParentPassword && ( + <> + + {(!!pathError) && ( + + {pathError} + + )} + + )} +
    + + + + {t('Create a derived account')} + + + + ); +} diff --git a/packages/extension-ui/src/Popup/Derive/index.tsx b/packages/extension-ui/src/Popup/Derive/index.tsx new file mode 100644 index 0000000..5289e9a --- /dev/null +++ b/packages/extension-ui/src/Popup/Derive/index.tsx @@ -0,0 +1,104 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import React, { useCallback, useContext, useMemo, useState } from 'react'; +import { useParams } from 'react-router'; + +import { AccountContext, AccountNamePasswordCreation, ActionContext, Address } from '../../components/index.js'; +import { useTranslation } from '../../hooks/index.js'; +import { deriveAccount } from '../../messaging.js'; +import { HeaderWithSteps } from '../../partials/index.js'; +import SelectParent from './SelectParent.js'; + +interface Props { + isLocked?: boolean; +} + +interface AddressState { + address: string; +} + +interface PathState extends AddressState { + suri: string; +} + +interface ConfirmState { + account: PathState; + parentPassword: string; +} + +function Derive ({ isLocked }: Props): React.ReactElement { + const { t } = useTranslation(); + const onAction = useContext(ActionContext); + const { accounts } = useContext(AccountContext); + const { address: parentAddress } = useParams(); + const [isBusy, setIsBusy] = useState(false); + const [account, setAccount] = useState(null); + const [name, setName] = useState(null); + const [parentPassword, setParentPassword] = useState(null); + + const parentGenesis = useMemo( + () => accounts.find((a) => a.address === parentAddress)?.genesisHash || null, + [accounts, parentAddress] + ); + + const _onCreate = useCallback((name: string, password: string) => { + if (!account || !name || !password || !parentPassword) { + return; + } + + setIsBusy(true); + deriveAccount(parentAddress, account.suri, parentPassword, name, password, parentGenesis) + .then(() => onAction('/')) + .catch((error): void => { + setIsBusy(false); + console.error(error); + }); + }, [account, onAction, parentAddress, parentGenesis, parentPassword]); + + const _onDerivationConfirmed = useCallback(({ account, parentPassword }: ConfirmState) => { + setAccount(account); + setParentPassword(parentPassword); + }, []); + + const _onBackClick = useCallback(() => { + setAccount(null); + }, []); + + return ( + <> + + {!account && ( + + )} + {account && ( + <> +
    +
    +
    + + + )} + + ); +} + +export default React.memo(Derive); diff --git a/packages/extension-ui/src/Popup/Export.spec.tsx b/packages/extension-ui/src/Popup/Export.spec.tsx new file mode 100644 index 0000000..121ddbe --- /dev/null +++ b/packages/extension-ui/src/Popup/Export.spec.tsx @@ -0,0 +1,107 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import '@pezkuwi/extension-mocks/chrome'; + +import type { ReactWrapper } from 'enzyme'; +import type * as _ from '@pezkuwi/dev-test/globals.d.ts'; +import type { KeyringPair$Json } from '@pezkuwi/keyring/types'; + +import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; +import enzyme from 'enzyme'; +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { MemoryRouter, Route } from 'react-router'; + +import { Button } from '../components/index.js'; +import * as messaging from '../messaging.js'; +import { flushAllPromises } from '../testHelpers.js'; +import Export from './Export.js'; + +const { configure, mount } = enzyme; + +// // NOTE Required for spyOn when using @swc/jest +// // https://github.com/swc-project/swc/issues/3843 +// jest.mock('../messaging', (): Record => ({ +// __esModule: true, +// ...jest.requireActual('../messaging') +// })); + +// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-call +configure({ adapter: new Adapter() }); + +describe('Export component', () => { + let wrapper: ReactWrapper; + const VALID_ADDRESS = 'HjoBp62cvsWDA3vtNMWxz6c9q13ReEHi9UGHK7JbZweH5g5'; + + const enterPassword = (password = 'any password'): void => { + wrapper.find('[data-export-password] input').simulate('change', { target: { value: password } }); + }; + + beforeEach(() => { + jest.spyOn(messaging, 'exportAccount').mockImplementation(() => Promise.resolve({ exportedJson: { meta: { name: 'account_name' } } as unknown as KeyringPair$Json })); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + wrapper = mount( + + + + ); + }); + + it('creates export message on button press', async () => { + enterPassword('passw0rd'); + wrapper.find('[data-export-button] button').simulate('click'); + await act(flushAllPromises); + + expect(messaging.exportAccount).toHaveBeenCalledWith(VALID_ADDRESS, 'passw0rd'); + }); + + it('button is disabled before any password is typed', () => { + expect(wrapper.find(Button).prop('isDisabled')).toBe(true); + }); + + it('shows an error if the password is wrong', async () => { + // silencing the following expected console.error + console.error = jest.fn(); + // eslint-disable-next-line @typescript-eslint/require-await + jest.spyOn(messaging, 'exportAccount').mockImplementation(async () => { + throw new Error('Unable to decode using the supplied passphrase'); + }); + enterPassword(); + wrapper.find('[data-export-button] button').simulate('click'); + await act(flushAllPromises); + wrapper.update(); + + // the first message is "You are exporting your account. Keep it safe and don't share it with anyone." + expect(wrapper.find('.warning-message').at(1).text()).toBe('Unable to decode using the supplied passphrase'); + expect(wrapper.find(Button).prop('isDisabled')).toBe(true); + expect(wrapper.find('InputWithLabel').first().prop('isError')).toBe(true); + }); + + it('shows no error when typing again after a wrong password', async () => { + // silencing the following expected console.error + console.error = jest.fn(); + // eslint-disable-next-line @typescript-eslint/require-await + jest.spyOn(messaging, 'exportAccount').mockImplementation(async () => { + throw new Error('Unable to decode using the supplied passphrase'); + }); + enterPassword(); + wrapper.find('[data-export-button] button').simulate('click'); + await act(flushAllPromises); + wrapper.update(); + enterPassword(); + + // the first message is "You are exporting your account. Keep it safe and don't share it with anyone." + expect(wrapper.find('.warning-message')).toHaveLength(1); + expect(wrapper.find(Button).prop('isDisabled')).toBe(false); + expect(wrapper.find('InputWithLabel').first().prop('isError')).toBe(false); + }); + + it('button is enabled after password is typed', async () => { + enterPassword(); + await act(flushAllPromises); + + expect(wrapper.find(Button).prop('isDisabled')).toBe(false); + }); +}); diff --git a/packages/extension-ui/src/Popup/Export.tsx b/packages/extension-ui/src/Popup/Export.tsx new file mode 100644 index 0000000..4fa4d78 --- /dev/null +++ b/packages/extension-ui/src/Popup/Export.tsx @@ -0,0 +1,135 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { RouteComponentProps } from 'react-router'; + +import fileSaver from 'file-saver'; +import React, { useCallback, useContext, useState } from 'react'; +import { withRouter } from 'react-router'; + +import { ActionBar, ActionContext, ActionText, Address, Button, InputWithLabel, Warning } from '../components/index.js'; +import { useTranslation } from '../hooks/index.js'; +import { exportAccount } from '../messaging.js'; +import { Header } from '../partials/index.js'; +import { styled } from '../styled.js'; + +const MIN_LENGTH = 6; + +interface Props extends RouteComponentProps<{address: string}> { + className?: string; +} + +function Export ({ className, match: { params: { address } } }: Props): React.ReactElement { + const { t } = useTranslation(); + const onAction = useContext(ActionContext); + const [isBusy, setIsBusy] = useState(false); + const [pass, setPass] = useState(''); + const [error, setError] = useState(''); + + const _goHome = useCallback( + () => onAction('/'), + [onAction] + ); + + const onPassChange = useCallback( + (password: string) => { + setPass(password); + setError(''); + } + , []); + + const _onExportButtonClick = useCallback( + (): void => { + setIsBusy(true); + + exportAccount(address, pass) + .then(({ exportedJson }) => { + const blob = new Blob([JSON.stringify(exportedJson)], { type: 'application/json; charset=utf-8' }); + + // eslint-disable-next-line deprecation/deprecation + fileSaver.saveAs(blob, `${address}.json`); + + onAction('/'); + }) + .catch((error: Error) => { + console.error(error); + setError(error.message); + setIsBusy(false); + }); + }, + [address, onAction, pass] + ); + + return ( + <> +
    +
    +
    + + {t("You are exporting your account. Keep it safe and don't share it with anyone.")} + +
    + + {error && ( + + {error} + + )} + + + + +
    +
    +
    + + ); +} + +export default withRouter(styled(Export)` + .actionArea { + padding: 10px 24px; + } + + .center { + margin: auto; + } + + .export-button { + margin-top: 6px; + } + + .movedWarning { + margin-top: 8px; + } + + .withMarginTop { + margin-top: 4px; + } +`); diff --git a/packages/extension-ui/src/Popup/ExportAll.tsx b/packages/extension-ui/src/Popup/ExportAll.tsx new file mode 100644 index 0000000..c42e6ca --- /dev/null +++ b/packages/extension-ui/src/Popup/ExportAll.tsx @@ -0,0 +1,131 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { RouteComponentProps } from 'react-router'; + +import fileSaver from 'file-saver'; +import React, { useCallback, useContext, useState } from 'react'; +import { withRouter } from 'react-router'; + +import { AccountContext, ActionBar, ActionContext, ActionText, Button, InputWithLabel, Warning } from '../components/index.js'; +import { useTranslation } from '../hooks/index.js'; +import { exportAccounts } from '../messaging.js'; +import { Header } from '../partials/index.js'; +import { styled } from '../styled.js'; + +const MIN_LENGTH = 6; + +interface Props extends RouteComponentProps { + className?: string; +} + +function ExportAll ({ className }: Props): React.ReactElement { + const { t } = useTranslation(); + const { accounts } = useContext(AccountContext); + const onAction = useContext(ActionContext); + const [isBusy, setIsBusy] = useState(false); + const [pass, setPass] = useState(''); + const [error, setError] = useState(''); + + const _goHome = useCallback( + () => onAction('/'), + [onAction] + ); + + const onPassChange = useCallback( + (password: string) => { + setPass(password); + setError(''); + } + , []); + + const _onExportAllButtonClick = useCallback( + (): void => { + setIsBusy(true); + + exportAccounts(accounts.map((account) => account.address), pass) + .then(({ exportedJson }) => { + const blob = new Blob([JSON.stringify(exportedJson)], { type: 'application/json; charset=utf-8' }); + + // eslint-disable-next-line deprecation/deprecation + fileSaver.saveAs(blob, `batch_exported_account_${Date.now()}.json`); + + onAction('/'); + }) + .catch((error: Error) => { + console.error(error); + setError(error.message); + setIsBusy(false); + }); + }, + [accounts, onAction, pass] + ); + + return ( + <> +
    +
    +
    + + {error && ( + + {error} + + )} + + + + +
    +
    + + ); +} + +export default withRouter(styled(ExportAll)` + .actionArea { + padding: 10px 24px; + } + + .center { + margin: auto; + } + + .export-button { + margin-top: 6px; + } + + .movedWarning { + margin-top: 8px; + } + + .withMarginTop { + margin-top: 4px; + } +`); diff --git a/packages/extension-ui/src/Popup/Forget.tsx b/packages/extension-ui/src/Popup/Forget.tsx new file mode 100644 index 0000000..dfeb45c --- /dev/null +++ b/packages/extension-ui/src/Popup/Forget.tsx @@ -0,0 +1,94 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { RouteComponentProps } from 'react-router'; + +import React, { useCallback, useContext, useState } from 'react'; +import { withRouter } from 'react-router'; + +import { ActionBar, ActionContext, ActionText, Address, Button, Warning } from '../components/index.js'; +import { useTranslation } from '../hooks/index.js'; +import { forgetAccount } from '../messaging.js'; +import { Header } from '../partials/index.js'; +import { styled } from '../styled.js'; + +interface Props extends RouteComponentProps<{ address: string }> { + className?: string; +} + +function Forget ({ className, match: { params: { address } } }: Props): React.ReactElement { + const { t } = useTranslation(); + const onAction = useContext(ActionContext); + const [isBusy, setIsBusy] = useState(false); + + const _goHome = useCallback( + () => onAction('/'), + [onAction] + ); + + const _onClick = useCallback( + (): void => { + setIsBusy(true); + forgetAccount(address) + .then(() => { + setIsBusy(false); + onAction('/'); + }) + .catch((error: Error) => { + setIsBusy(false); + console.error(error); + }); + }, + [address, onAction] + ); + + return ( + <> +
    +
    +
    + + {t('You are about to remove the account. This means that you will not be able to access it via this extension anymore. If you wish to recover it, you would need to use the seed.')} + +
    + + + + +
    +
    +
    + + ); +} + +export default withRouter(styled(Forget)` + .actionArea { + padding: 10px 24px; + } + + .center { + margin: auto; + } + + .movedWarning { + margin-top: 8px; + } + + .withMarginTop { + margin-top: 4px; + } +`); diff --git a/packages/extension-ui/src/Popup/ImportLedger.tsx b/packages/extension-ui/src/Popup/ImportLedger.tsx new file mode 100644 index 0000000..7c1035b --- /dev/null +++ b/packages/extension-ui/src/Popup/ImportLedger.tsx @@ -0,0 +1,201 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { HexString } from '@pezkuwi/util/types'; + +import { faSync } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import React, { useCallback, useContext, useEffect, useRef, useState } from 'react'; + +import { settings } from '@pezkuwi/ui-settings'; + +import { ActionContext, Address, Button, ButtonArea, Dropdown, Switch, VerticalSpace, Warning } from '../components/index.js'; +import { useLedger, useTranslation } from '../hooks/index.js'; +import { createAccountHardware } from '../messaging.js'; +import { Header, Name } from '../partials/index.js'; +import { styled } from '../styled.js'; +import ledgerChains from '../util/legerChains.js'; + +interface AccOption { + text: string; + value: number; +} + +interface NetworkOption { + text: string; + value: string | null; +} + +const AVAIL: number[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]; + +interface Props { + className?: string; +} + +function ImportLedger ({ className }: Props): React.ReactElement { + const { t } = useTranslation(); + const [accountIndex, setAccountIndex] = useState(0); + const [addressOffset, setAddressOffset] = useState(0); + const [error, setError] = useState(null); + const [isBusy, setIsBusy] = useState(false); + const [genesis, setGenesis] = useState(null); + const [isEthereum, setIsEthereum] = useState(false); + const onAction = useContext(ActionContext); + const [name, setName] = useState(null); + const { address, error: ledgerError, isLoading: ledgerLoading, isLocked: ledgerLocked, refresh, type, warning: ledgerWarning } = useLedger(genesis, accountIndex, addressOffset, isEthereum); + + useEffect(() => { + if (address) { + settings.set({ ledgerConn: 'webusb' }); + } + }, [address]); + + const accOps = useRef(AVAIL.map((value): AccOption => ({ + text: t('Account type {{index}}', { replace: { index: value } }), + value + }))); + + const addOps = useRef(AVAIL.map((value): AccOption => ({ + text: t('Address index {{index}}', { replace: { index: value } }), + value + }))); + + const networkOps = useRef( + [{ + text: t('Select network'), + value: '' + }, + ...ledgerChains.map(({ displayName, genesisHash }): NetworkOption => ({ + text: displayName, + value: genesisHash[0] + }))] + ); + + const _onSave = useCallback( + () => { + if (address && genesis && name && type) { + setIsBusy(true); + + createAccountHardware(address, 'ledger', accountIndex, addressOffset, name, genesis, type) + .then(() => onAction('/')) + .catch((error: Error) => { + console.error(error); + + setIsBusy(false); + setError(error.message); + }); + } + }, + [accountIndex, address, addressOffset, genesis, name, onAction, type] + ); + + // select element is returning a string + const _onSetAccountIndex = useCallback((value: number) => setAccountIndex(Number(value)), []); + const _onSetAddressOffset = useCallback((value: number) => setAddressOffset(Number(value)), []); + + return ( + <> +
    +
    +
    +
    + +
    + + {!!genesis && !!address && !ledgerError && ( + + )} + {!!name && ( + <> + + + + + )} + {!!ledgerWarning && ( + + {ledgerWarning} + + )} + {(!!error || !!ledgerError) && ( + + {error || ledgerError} + + )} +
    + + + {ledgerLocked + ? ( + + ) + : ( + + ) + } + + + ); +} + +export default styled(ImportLedger)` + .refreshIcon { + margin-right: 0.3rem; + } + + .ethereum-toggle { + margin: 1rem 0; + } +`; diff --git a/packages/extension-ui/src/Popup/ImportQr.spec.tsx b/packages/extension-ui/src/Popup/ImportQr.spec.tsx new file mode 100644 index 0000000..94c87c2 --- /dev/null +++ b/packages/extension-ui/src/Popup/ImportQr.spec.tsx @@ -0,0 +1,140 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import '@pezkuwi/extension-mocks/chrome'; + +import type { ReactWrapper } from 'enzyme'; +import type * as _ from '@pezkuwi/dev-test/globals.d.ts'; + +import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; +import enzyme from 'enzyme'; +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { MemoryRouter } from 'react-router'; + +import { Button } from '../components/index.js'; +import * as messaging from '../messaging.js'; +import { flushAllPromises } from '../testHelpers.js'; +import ImportQr from './ImportQr.js'; + +const { configure, mount } = enzyme; + +const mockedAccount = { + content: '12bxf6QJS5hMJgwbJMDjFot1sq93EvgQwyuPWENr9SzJfxtN', + expectedBannerChain: 'Polkadot', + genesisHash: '0x91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3', + isAddress: true, + name: 'My Polkadot Account' +}; + +interface ScanType { + isAddress: boolean; + content: string; + genesisHash: string; + name?: string; +} + +interface QrScanAddressProps { + className?: string; + onError?: (error: Error) => void; + onScan: (scanned: ScanType) => void; + size?: string | number; + style?: React.CSSProperties; +} + +// // NOTE Required for spyOn when using @swc/jest +// // https://github.com/swc-project/swc/issues/3843 +// jest.mock('../messaging', (): Record => ({ +// __esModule: true, +// ...jest.requireActual('../messaging') +// })); + +// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-call +configure({ adapter: new Adapter() }); + +const typeName = async (wrapper: ReactWrapper, value: string) => { + wrapper.find('input').first().simulate('change', { target: { value } }); + await act(flushAllPromises); + wrapper.update(); +}; + +// jest.mock('@pezkuwi/react-qr', () => { +// return { +// QrScanAddress: (_: QrScanAddressProps): null => { +// return null; +// } +// }; +// }); + +describe('ImportQr component', () => { + let wrapper: ReactWrapper; + + beforeEach(async () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + wrapper = mount( + + + + ); + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + act(() => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + (wrapper.find('QrScanAddress').first().prop('onScan') as unknown as QrScanAddressProps['onScan'])(mockedAccount); + }); + await act(flushAllPromises); + wrapper.update(); + }); + + describe('Address component', () => { + it('shows account as external', () => { + expect(wrapper.find('Name').find('FontAwesomeIcon [data-icon="qrcode"]').exists()).toBe(true); + }); + + it('shows the correct name', () => { + expect(wrapper.find('Name span').text()).toEqual(mockedAccount.name); + }); + + it('shows the correct address', () => { + expect(wrapper.find('[data-field="address"]').text()).toEqual(mockedAccount.content); + }); + + it('shows the correct banner', () => { + expect(wrapper.find('[data-field="chain"]').text()).toEqual(mockedAccount.expectedBannerChain); + }); + }); + + it('has the button enabled', () => { + expect(wrapper.find(Button).prop('isDisabled')).toBe(false); + }); + + it('displays and error and the button is disabled with a short name', async () => { + await typeName(wrapper, 'a'); + + expect(wrapper.find('.warning-message').first().text()).toBe('Account name is too short'); + expect(wrapper.find(Button).prop('isDisabled')).toBe(true); + }); + + it('has no error message and button enabled with a long name', async () => { + const longName = 'aaa'; + + await typeName(wrapper, 'a'); + await typeName(wrapper, longName); + + expect(wrapper.find('.warning-message')).toHaveLength(0); + expect(wrapper.find(Button).prop('isDisabled')).toBe(false); + expect(wrapper.find('Name span').text()).toEqual(longName); + }); + + it('shows the external name in the input field', () => { + expect(wrapper.find('input').prop('value')).toBe(mockedAccount.name); + }); + + it('creates the external account', async () => { + jest.spyOn(messaging, 'createAccountExternal').mockImplementation(() => Promise.resolve(false)); + wrapper.find(Button).simulate('click'); + await act(flushAllPromises); + + expect(messaging.createAccountExternal).toHaveBeenCalledWith(mockedAccount.name, mockedAccount.content, mockedAccount.genesisHash); + }); +}); diff --git a/packages/extension-ui/src/Popup/ImportQr.tsx b/packages/extension-ui/src/Popup/ImportQr.tsx new file mode 100644 index 0000000..8ff0e9b --- /dev/null +++ b/packages/extension-ui/src/Popup/ImportQr.tsx @@ -0,0 +1,115 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { HexString } from '@pezkuwi/util/types'; + +import React, { useCallback, useContext, useState } from 'react'; + +import { QrScanAddress } from '@pezkuwi/react-qr'; + +import AccountNamePasswordCreation from '../components/AccountNamePasswordCreation.js'; +import { ActionContext, Address, ButtonArea, NextStepButton, VerticalSpace } from '../components/index.js'; +import { useTranslation } from '../hooks/index.js'; +import { createAccountExternal, createAccountSuri, createSeed } from '../messaging.js'; +import { Header, Name } from '../partials/index.js'; + +interface QrAccount { + content: string; + genesisHash: HexString | null; + isAddress: boolean; + name?: string; +} + +export default function ImportQr (): React.ReactElement { + const { t } = useTranslation(); + const onAction = useContext(ActionContext); + const [account, setAccount] = useState(null); + const [address, setAddress] = useState(null); + const [name, setName] = useState(null); + const [password, setPassword] = useState(null); + + const _setAccount = useCallback( + (qrAccount: QrAccount) => { + setAccount(qrAccount); + setName(qrAccount?.name || null); + + if (qrAccount.isAddress) { + setAddress(qrAccount.content); + } else { + createSeed(undefined, qrAccount.content) + .then(({ address }) => setAddress(address)) + .catch(console.error); + } + }, + [] + ); + + const _onCreate = useCallback( + (): void => { + if (account && name) { + if (account.isAddress) { + createAccountExternal(name, account.content, account.genesisHash) + .then(() => onAction('/')) + .catch((error: Error) => console.error(error)); + } else if (password) { + createAccountSuri(name, password, account.content, 'sr25519', account.genesisHash) + .then(() => onAction('/')) + .catch((error: Error) => console.error(error)); + } + } + }, + [account, name, onAction, password] + ); + + return ( + <> +
    + {!account && ( +
    + +
    + )} + {account && ( + <> +
    +
    +
    + {account.isAddress + ? ( + + ) + : ( + + ) + } + + + + {t('Add the account with identified address')} + + + + )} + + ); +} diff --git a/packages/extension-ui/src/Popup/ImportSeed/ImportSeed.spec.tsx b/packages/extension-ui/src/Popup/ImportSeed/ImportSeed.spec.tsx new file mode 100644 index 0000000..5cdb9f1 --- /dev/null +++ b/packages/extension-ui/src/Popup/ImportSeed/ImportSeed.spec.tsx @@ -0,0 +1,204 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import '@pezkuwi/extension-mocks/chrome'; + +import type { ReactWrapper } from 'enzyme'; +import type * as _ from '@pezkuwi/dev-test/globals.d.ts'; + +import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; +import enzyme from 'enzyme'; +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { MemoryRouter } from 'react-router'; + +import { ActionContext, Button, Warning } from '../../components/index.js'; +import * as messaging from '../../messaging.js'; +import { flushAllPromises } from '../../testHelpers.js'; +import ImportSeed from './index.js'; + +const { configure, mount } = enzyme; + +const account = { + derivation: '/1', + expectedAddress: '5GNg7RWeAAJuya4wTxb8aZf19bCWJroKuJNrhk4N3iYHNqTm', + expectedAddressWithDerivation: '5DV3x9zgaXREUMTX7GgkP3ETeW4DQAznWTxg4kx2WivGuQLQ', + name: 'My Polkadot Account', + password: 'somePassword', + seed: 'upgrade multiply predict hip multiply march leg devote social outer oppose debris' +}; + +// // NOTE Required for spyOn when using @swc/jest +// // https://github.com/swc-project/swc/issues/3843 +// jest.mock('../../messaging', (): Record => ({ +// __esModule: true, +// ...jest.requireActual('../../messaging') +// })); + +// For this file, there are a lot of them +/* eslint-disable @typescript-eslint/no-unsafe-argument */ + +// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-call +configure({ adapter: new Adapter() }); + +jest.spyOn(messaging, 'getAllMetadata').mockImplementation(() => Promise.resolve([])); + +const typeSeed = async (wrapper: ReactWrapper, value: string) => { + wrapper.find('textarea').first().simulate('change', { target: { value } }); + await act(flushAllPromises); + wrapper.update(); +}; + +const typeDerivationPath = async (wrapper: ReactWrapper, value: string) => { + wrapper.find('input').first().simulate('change', { target: { value } }); + await act(flushAllPromises); + wrapper.update(); +}; + +// FIXME hanging +// eslint-disable-next-line jest/no-disabled-tests +describe.skip('ImportSeed', () => { + let wrapper: ReactWrapper; + const onActionStub = jest.fn(); + + const type = async (input: ReactWrapper, value: string): Promise => { + input.simulate('change', { target: { value } }); + await act(flushAllPromises); + wrapper.update(); + }; + + const enterName = (name: string): Promise => type(wrapper.find('input').first(), name); + const password = (password: string) => (): Promise => type(wrapper.find('input[type="password"]').first(), password); + const repeat = (password: string) => (): Promise => type(wrapper.find('input[type="password"]').last(), password); + + beforeEach(async () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + wrapper = mount( + + + + + + ); + + await act(flushAllPromises); + wrapper.update(); + }); + + describe('Step 1', () => { + it('first shows no error, no account, and next step button is disabled', () => { + expect(wrapper.find('Name span').text()).toEqual(''); + expect(wrapper.find('[data-field="address"]').text()).toEqual(''); + expect(wrapper.find('.derivationPath').exists()).toBe(false); + expect(wrapper.find(Warning).exists()).toBe(false); + expect(wrapper.find(Button).prop('isDisabled')).toBe(true); + }); + + it('shows the expected account when correct seed is typed and next step button is enabled', async () => { + jest.spyOn(messaging, 'validateSeed').mockImplementation(() => Promise.resolve({ address: account.expectedAddress, suri: account.seed })); + await typeSeed(wrapper, account.seed); + + expect(wrapper.find(Warning).exists()).toBe(false); + expect(wrapper.find(Button).prop('isDisabled')).toBe(false); + expect(wrapper.find('Name span').text()).toEqual(''); + expect(wrapper.find('[data-field="address"]').text()).toEqual(account.expectedAddress); + }); + + it('shows an error when incorrect seed is typed and next step button is enabled', async () => { + // silencing the following expected console.error + console.error = jest.fn(); + // eslint-disable-next-line @typescript-eslint/require-await + jest.spyOn(messaging, 'validateSeed').mockImplementation(async () => { + throw new Error('Some test error message'); + }); + await typeSeed(wrapper, 'this is an invalid mnemonic seed'); + + expect(wrapper.find(Warning).find('.warning-message').text()).toEqual('Invalid mnemonic seed'); + expect(wrapper.find(Button).prop('isDisabled')).toBe(true); + expect(wrapper.find('Name span').text()).toEqual(''); + expect(wrapper.find('[data-field="address"]').text()).toEqual(''); + }); + + it('shows an error when the seed is removed', async () => { + await typeSeed(wrapper, 'asdf'); + await typeSeed(wrapper, ''); + + expect(wrapper.find(Warning).find('.warning-message').text()).toEqual('Mnemonic needs to contain 12, 15, 18, 21, 24 words'); + expect(wrapper.find(Button).prop('isDisabled')).toBe(true); + }); + + it('shows the expected account with derivation when correct seed is typed and next step button is enabled', async () => { + const suri = `${account.seed}${account.derivation}`; + const validateCall = jest.spyOn(messaging, 'validateSeed').mockImplementation(() => Promise.resolve({ address: account.expectedAddressWithDerivation, suri })); + + await typeSeed(wrapper, account.seed); + wrapper.find('.advancedToggle').simulate('click'); + await typeDerivationPath(wrapper, account.derivation); + + expect(validateCall).toHaveBeenLastCalledWith(suri); + expect(wrapper.find(Warning).exists()).toBe(false); + expect(wrapper.find(Button).prop('isDisabled')).toBe(false); + expect(wrapper.find('Name span').text()).toEqual(''); + expect(wrapper.find('[data-field="address"]').text()).toEqual(account.expectedAddressWithDerivation); + }); + + it('shows an error when derivation path is incorrect and next step button is disabled', async () => { + const wrongPath = 'wrong'; + const suri = `${account.seed}${wrongPath}`; + + // eslint-disable-next-line @typescript-eslint/require-await + const validateCall = jest.spyOn(messaging, 'validateSeed').mockImplementation(async () => { + throw new Error('Some test error message'); + }); + + await typeSeed(wrapper, account.seed); + wrapper.find('.advancedToggle').simulate('click'); + await typeDerivationPath(wrapper, wrongPath); + + expect(validateCall).toHaveBeenLastCalledWith(suri); + expect(wrapper.find(Warning).find('.warning-message').text()).toEqual('Invalid mnemonic seed or derivation path'); + expect(wrapper.find(Button).prop('isDisabled')).toBe(true); + expect(wrapper.find('Name span').text()).toEqual(''); + expect(wrapper.find('[data-field="address"]').text()).toEqual(''); + }); + + it('moves to the second step', async () => { + jest.spyOn(messaging, 'validateSeed').mockImplementation(() => Promise.resolve({ address: account.expectedAddress, suri: account.seed })); + await typeSeed(wrapper, account.seed); + wrapper.find(Button).simulate('click'); + await act(flushAllPromises); + wrapper.update(); + + expect(wrapper.find(Button)).toHaveLength(2); + expect(wrapper.find('Name span').text()).toEqual(''); + expect(wrapper.find('[data-field="address"]').text()).toEqual(account.expectedAddress); + }); + + describe('Phase 2', () => { + const suri = `${account.seed}${account.derivation}`; + + beforeEach(async () => { + jest.spyOn(messaging, 'createAccountSuri').mockImplementation(() => Promise.resolve(true)); + jest.spyOn(messaging, 'validateSeed').mockImplementation(() => Promise.resolve({ address: account.expectedAddressWithDerivation, suri })); + + await typeSeed(wrapper, account.seed); + wrapper.find('.advancedToggle').simulate('click'); + await typeDerivationPath(wrapper, account.derivation); + wrapper.find(Button).simulate('click'); + + await act(flushAllPromises); + wrapper.update(); + }); + + it('saves account with provided name and password', async () => { + await enterName(account.name).then(password(account.password)).then(repeat(account.password)); + wrapper.find('[data-button-action="add new root"] button').simulate('click'); + await act(flushAllPromises); + wrapper.update(); + + expect(messaging.createAccountSuri).toHaveBeenCalledWith(account.name, account.password, suri, undefined, ''); + expect(onActionStub).toHaveBeenCalledWith('/'); + }); + }); + }); +}); diff --git a/packages/extension-ui/src/Popup/ImportSeed/SeedAndPath.tsx b/packages/extension-ui/src/Popup/ImportSeed/SeedAndPath.tsx new file mode 100644 index 0000000..ba3b18c --- /dev/null +++ b/packages/extension-ui/src/Popup/ImportSeed/SeedAndPath.tsx @@ -0,0 +1,163 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { KeypairType } from '@pezkuwi/util-crypto/types'; +import type { AccountInfo } from './index.js'; + +import { faCaretDown, faCaretRight } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import React, { useCallback, useEffect, useState } from 'react'; + +import { validateSeed } from '@pezkuwi/extension-ui/messaging'; +import { objectSpread } from '@pezkuwi/util'; + +import { ButtonArea, Dropdown, InputWithLabel, NextStepButton, TextAreaWithLabel, VerticalSpace, Warning } from '../../components/index.js'; +import { useGenesisHashOptions, useTranslation } from '../../hooks/index.js'; +import { styled } from '../../styled.js'; + +interface Props { + className?: string; + onNextStep: () => void; + onAccountChange: (account: AccountInfo | null) => void; + type: KeypairType; +} + +function SeedAndPath ({ className, onAccountChange, onNextStep, type }: Props): React.ReactElement { + const { t } = useTranslation(); + const genesisOptions = useGenesisHashOptions(); + const [address, setAddress] = useState(''); + const [seed, setSeed] = useState(null); + const [path, setPath] = useState(null); + const [advanced, setAdvances] = useState(false); + const [error, setError] = useState(''); + const [genesis, setGenesis] = useState(''); + + useEffect(() => { + // No need to validate an empty seed + // we have a dedicated error for this + if (!seed) { + onAccountChange(null); + + return; + } + + const suri = `${seed || ''}${path || ''}`; + + validateSeed(suri, type) + .then((validatedAccount) => { + setError(''); + setAddress(validatedAccount.address); + onAccountChange( + objectSpread({}, validatedAccount, { genesis, type }) + ); + }) + .catch(() => { + setAddress(''); + onAccountChange(null); + setError(path + ? t('Invalid mnemonic seed or derivation path') + : t('Invalid mnemonic seed') + ); + }); + }, [t, genesis, seed, path, onAccountChange, type]); + + const _onToggleAdvanced = useCallback(() => { + setAdvances(!advanced); + }, [advanced]); + + return ( + <> +
    + + {!!error && !seed && ( + + {t('Mnemonic needs to contain 12, 15, 18, 21, 24 words')} + + )} + +
    + + {t('advanced')} +
    + { advanced && ( + + )} + {!!error && !!seed && ( + + {error} + + )} +
    + + + + {t('Next')} + + + + ); +} + +export default styled(SeedAndPath)` + .advancedToggle { + color: var(--textColor); + cursor: pointer; + line-height: var(--lineHeight); + letter-spacing: 0.04em; + opacity: 0.65; + text-transform: uppercase; + + > span { + font-size: var(--inputLabelFontSize); + margin-left: .5rem; + vertical-align: middle; + } + } + + .genesisSelection { + margin-bottom: var(--fontSize); + } + + .seedInput { + margin-bottom: var(--fontSize); + textarea { + height: unset; + } + } + + .seedError { + margin-bottom: 1rem; + } +`; diff --git a/packages/extension-ui/src/Popup/ImportSeed/index.tsx b/packages/extension-ui/src/Popup/ImportSeed/index.tsx new file mode 100644 index 0000000..0c12640 --- /dev/null +++ b/packages/extension-ui/src/Popup/ImportSeed/index.tsx @@ -0,0 +1,104 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { HexString } from '@pezkuwi/util/types'; + +import React, { useCallback, useContext, useEffect, useState } from 'react'; + +import AccountNamePasswordCreation from '../../components/AccountNamePasswordCreation.js'; +import { AccountContext, ActionContext, Address } from '../../components/index.js'; +import { useMetadata, useTranslation } from '../../hooks/index.js'; +import { createAccountSuri } from '../../messaging.js'; +import { HeaderWithSteps } from '../../partials/index.js'; +import { DEFAULT_TYPE } from '../../util/defaultType.js'; +import SeedAndPath from './SeedAndPath.js'; + +export interface AccountInfo { + address: string; + genesis?: HexString; + suri: string; +} + +function ImportSeed (): React.ReactElement { + const { t } = useTranslation(); + const { accounts } = useContext(AccountContext); + const onAction = useContext(ActionContext); + const [isBusy, setIsBusy] = useState(false); + const [account, setAccount] = useState(null); + const [name, setName] = useState(null); + const [step1, setStep1] = useState(true); + const [type, setType] = useState(DEFAULT_TYPE); + const chain = useMetadata(account?.genesis, true); + + useEffect((): void => { + !accounts.length && onAction(); + }, [accounts, onAction]); + + useEffect((): void => { + setType( + chain && chain.definition.chainType === 'ethereum' + ? 'ethereum' + : DEFAULT_TYPE + ); + }, [chain]); + + const _onCreate = useCallback((name: string, password: string): void => { + // this should always be the case + if (name && password && account) { + setIsBusy(true); + + createAccountSuri(name, password, account.suri, type, account.genesis) + .then(() => onAction('/')) + .catch((error): void => { + setIsBusy(false); + console.error(error); + }); + } + }, [account, onAction, type]); + + const _onNextStep = useCallback( + () => setStep1(false), + [] + ); + + const _onBackClick = useCallback( + () => setStep1(true), + [] + ); + + return ( + <> + +
    +
    +
    + {step1 + ? ( + + ) + : ( + + ) + } + + ); +} + +export default ImportSeed; diff --git a/packages/extension-ui/src/Popup/Metadata/Request.tsx b/packages/extension-ui/src/Popup/Metadata/Request.tsx new file mode 100644 index 0000000..baf16e3 --- /dev/null +++ b/packages/extension-ui/src/Popup/Metadata/Request.tsx @@ -0,0 +1,129 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { MetadataDef } from '@pezkuwi/extension-inject/types'; + +import React, { useCallback, useContext } from 'react'; + +import { ActionBar, ActionContext, Button, Link, Table, Warning } from '../../components/index.js'; +import { useMetadata, useTranslation } from '../../hooks/index.js'; +import { approveMetaRequest, rejectMetaRequest } from '../../messaging.js'; +import { styled } from '../../styled.js'; + +interface Props { + className?: string; + request: MetadataDef; + metaId: string; + url: string; +} + +function Request ({ className, metaId, request, url }: Props): React.ReactElement { + const { t } = useTranslation(); + const chain = useMetadata(request.genesisHash); + const onAction = useContext(ActionContext); + + const _onApprove = useCallback( + (): void => { + approveMetaRequest(metaId) + .then(() => onAction()) + .catch(console.error); + }, + [metaId, onAction] + ); + + const _onReject = useCallback( + (): void => { + rejectMetaRequest(metaId) + .then(() => onAction()) + .catch(console.error); + }, + [metaId, onAction] + ); + + return ( +
    + + + + + + + + + + + + + + + + + + + + + + + + + +
    {t('from')}{url}
    {t('chain')}{request.chain}
    {t('icon')}{request.icon}
    {t('decimals')}{request.tokenDecimals}
    {t('symbol')}{request.tokenSymbol}
    {t('upgrade')}{chain ? chain.specVersion : t('')} -> {request.specVersion}
    +
    + + {t('This approval will add the metadata to your extension instance, allowing future requests to be decoded using this metadata. It will also allow the use of Ledger\'s Generic Polkadot App.')} + + + + + {t('Reject')} + + +
    +
    + ); +} + +export default styled(Request)` + .btnAccept { + margin: 25px auto 0; + width: 90%; + } + + .btnReject { + margin: 8px 0 15px 0; + text-decoration: underline; + } + + .icon { + background: var(--buttonBackgroundDanger); + color: white; + min-width: 18px; + width: 14px; + height: 18px; + font-size: 10px; + line-height: 20px; + margin: 16px 15px 0 1.35rem; + font-weight: 800; + padding-left: 0.5px; + } + + .requestInfo { + align-items: center; + background: var(--highlightedAreaBackground); + display: flex; + flex-direction: column; + margin-bottom: 8px; + } + + .requestWarning { + margin: 24px 24px 0 1.45rem; + } +`; diff --git a/packages/extension-ui/src/Popup/Metadata/index.tsx b/packages/extension-ui/src/Popup/Metadata/index.tsx new file mode 100644 index 0000000..da6d4e4 --- /dev/null +++ b/packages/extension-ui/src/Popup/Metadata/index.tsx @@ -0,0 +1,31 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import React, { useContext } from 'react'; + +import { Loading, MetadataReqContext } from '../../components/index.js'; +import { useTranslation } from '../../hooks/index.js'; +import { Header } from '../../partials/index.js'; +import Request from './Request.js'; + +export default function Metadata (): React.ReactElement { + const { t } = useTranslation(); + const requests = useContext(MetadataReqContext); + + return ( + <> +
    + {requests[0] + ? ( + + ) + : + } + + ); +} diff --git a/packages/extension-ui/src/Popup/PhishingDetected.tsx b/packages/extension-ui/src/Popup/PhishingDetected.tsx new file mode 100644 index 0000000..0073512 --- /dev/null +++ b/packages/extension-ui/src/Popup/PhishingDetected.tsx @@ -0,0 +1,60 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import React from 'react'; +import { Trans } from 'react-i18next'; +import { useParams } from 'react-router'; + +import { useTranslation } from '../hooks/index.js'; +import { Header } from '../partials/index.js'; +import { styled } from '../styled.js'; + +interface Props { + className?: string; +} + +interface WebsiteState { + website: string; +} + +function PhishingDetected ({ className }: Props): React.ReactElement { + const { t } = useTranslation(); + const { website } = useParams(); + const decodedWebsite = decodeURIComponent(website); + + return ( + <> +
    +
    +

    + {t('You have been redirected because the Polkadot{.js} extension believes that this website could compromise the security of your accounts and your tokens.')} +

    +

    + {decodedWebsite} +

    +

    + + Note that this website was reported on a community-driven, curated list. It might be incomplete or inaccurate. If you think that this website was flagged incorrectly, please open an issue by clicking here. + +

    +
    + + ); +} + +export default styled(PhishingDetected)` + p { + color: var(--subTextColor); + margin-bottom: 1rem; + margin-top: 0; + + a { + color: var(--subTextColor); + } + + &.websiteAddress { + font-size: 1.3rem; + text-align: center; + } + } +`; diff --git a/packages/extension-ui/src/Popup/RestoreJson.tsx b/packages/extension-ui/src/Popup/RestoreJson.tsx new file mode 100644 index 0000000..c78bdc7 --- /dev/null +++ b/packages/extension-ui/src/Popup/RestoreJson.tsx @@ -0,0 +1,181 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { ResponseJsonGetAccountInfo } from '@pezkuwi/extension-base/background/types'; +import type { KeyringPair$Json } from '@pezkuwi/keyring/types'; +import type { KeyringPairs$Json } from '@pezkuwi/ui-keyring/types'; + +import React, { useCallback, useContext, useEffect, useState } from 'react'; + +import { u8aToString } from '@pezkuwi/util'; + +import { AccountContext, ActionContext, Address, Button, InputFileWithLabel, InputWithLabel, Warning } from '../components/index.js'; +import { useTranslation } from '../hooks/index.js'; +import { batchRestore, jsonGetAccountInfo, jsonRestore } from '../messaging.js'; +import { Header } from '../partials/index.js'; +import { styled } from '../styled.js'; +import { DEFAULT_TYPE } from '../util/defaultType.js'; +import { isKeyringPairs$Json } from '../util/typeGuards.js'; + +const acceptedFormats = ['application/json', 'text/plain'].join(', '); + +interface Props { + className?: string; +} + +function Upload ({ className }: Props): React.ReactElement { + const { t } = useTranslation(); + const { accounts } = useContext(AccountContext); + const onAction = useContext(ActionContext); + const [isBusy, setIsBusy] = useState(false); + const [accountsInfo, setAccountsInfo] = useState([]); + const [password, setPassword] = useState(''); + const [isFileError, setFileError] = useState(false); + const [requirePassword, setRequirePassword] = useState(false); + const [isPasswordError, setIsPasswordError] = useState(false); + // don't use the info from the file directly + // rather use what comes from the background from jsonGetAccountInfo + const [file, setFile] = useState(undefined); + + useEffect((): void => { + !accounts.length && onAction(); + }, [accounts, onAction]); + + const _onChangePass = useCallback( + (pass: string): void => { + setPassword(pass); + setIsPasswordError(false); + }, [] + ); + + const _onChangeFile = useCallback( + (file: Uint8Array): void => { + setAccountsInfo(() => []); + + let json: KeyringPair$Json | KeyringPairs$Json | undefined; + + try { + json = JSON.parse(u8aToString(file)) as KeyringPair$Json | KeyringPairs$Json; + setFile(json); + } catch (e) { + console.error(e); + setFileError(true); + } + + if (json === undefined) { + return; + } + + if (isKeyringPairs$Json(json)) { + setRequirePassword(true); + json.accounts.forEach((account) => { + setAccountsInfo((old) => [...old, { + address: account.address, + genesisHash: account.meta.genesisHash, + name: account.meta.name + } as ResponseJsonGetAccountInfo]); + }); + } else { + setRequirePassword(true); + jsonGetAccountInfo(json) + .then((accountInfo) => setAccountsInfo((old) => [...old, accountInfo])) + .catch((e) => { + setFileError(true); + console.error(e); + }); + } + }, [] + ); + + const _onRestore = useCallback( + (): void => { + if (!file) { + return; + } + + if (requirePassword && !password) { + return; + } + + setIsBusy(true); + + (isKeyringPairs$Json(file) ? batchRestore(file, password) : jsonRestore(file, password)) + .then(() => { + onAction('/'); + }) + .catch((e) => { + console.error(e); + setIsBusy(false); + setIsPasswordError(true); + }); + }, + [file, onAction, password, requirePassword] + ); + + return ( + <> +
    +
    + {accountsInfo.map(({ address, genesisHash, name, type = DEFAULT_TYPE }, index) => ( +
    + ))} + + {isFileError && ( + + {t('Invalid Json file')} + + )} + {requirePassword && ( +
    + + {isPasswordError && ( + + {t('Unable to decode using the supplied passphrase')} + + )} +
    + )} + +
    + + ); +} + +export default styled(Upload)` + .restoreButton { + margin-top: 16px; + } +`; diff --git a/packages/extension-ui/src/Popup/Signing/Bytes.tsx b/packages/extension-ui/src/Popup/Signing/Bytes.tsx new file mode 100644 index 0000000..c05b456 --- /dev/null +++ b/packages/extension-ui/src/Popup/Signing/Bytes.tsx @@ -0,0 +1,84 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import React, { useMemo } from 'react'; + +import { isAscii, u8aToString, u8aUnwrapBytes } from '@pezkuwi/util'; + +import { useTranslation } from '../../hooks/index.js'; +import { styled } from '../../styled.js'; + +interface Props { + className?: string; + bytes: string; + url: string; +} + +function Bytes ({ bytes, className, url }: Props): React.ReactElement { + const { t } = useTranslation(); + + const text = useMemo( + () => isAscii(bytes) + ? u8aToString(u8aUnwrapBytes(bytes)) + : bytes, + [bytes] + ); + + return ( + + + + + + + + + + + +
    {t('from')}{url}
    {t('bytes')}
    {text}
    + ); +} + +export default styled(Bytes)` + border: 0; + display: block; + font-size: 0.75rem; + margin-top: 0.75rem; + + td.data { + max-width: 0; + overflow: hidden; + text-align: left; + text-overflow: ellipsis; + vertical-align: middle; + width: 100%; + padding: 0.15rem; + + &.pre { + padding: 0px; + + div { + padding: 0.15rem; + font-family: inherit; + font-size: 0.75rem; + margin: 0; + white-space: pre; + overflow: auto; + max-height: calc(100vh - 480px); + min-height: var(--boxLineHeight); + border: 1px solid var(--boxBorderColor); + background: var(--boxBackground); + line-height: var(--boxLineHeight); + } + } + } + + td.label { + opacity: 0.5; + padding: 0 0.5rem; + text-align: right; + vertical-align: middle; + white-space: nowrap; + } +`; diff --git a/packages/extension-ui/src/Popup/Signing/Extrinsic.tsx b/packages/extension-ui/src/Popup/Signing/Extrinsic.tsx new file mode 100644 index 0000000..4147146 --- /dev/null +++ b/packages/extension-ui/src/Popup/Signing/Extrinsic.tsx @@ -0,0 +1,180 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { Chain } from '@pezkuwi/extension-chains/types'; +import type { Call, ExtrinsicEra, ExtrinsicPayload } from '@pezkuwi/types/interfaces'; +import type { AnyJson, SignerPayloadJSON } from '@pezkuwi/types/types'; +import type { BN } from '@pezkuwi/util'; +import type { TFunction } from '../../hooks/useTranslation.js'; + +import { convertMultilocationToUrl } from '@paraspell/xcm-analyser'; +import React, { useMemo, useRef } from 'react'; + +import { bnToBn, formatNumber } from '@pezkuwi/util'; + +import { Table } from '../../components/index.js'; +import { useMetadata, useTranslation } from '../../hooks/index.js'; + +interface Decoded { + args: AnyJson | null; + method: Call | null; +} + +interface Props { + className?: string; + payload: ExtrinsicPayload; + request: SignerPayloadJSON; + url: string; +} + +function displayDecodeVersion (message: string, chain: Chain, specVersion: BN): string { + return `${message}: chain=${chain.name}, specVersion=${chain.specVersion.toString()} (request specVersion=${specVersion.toString()})`; +} + +function decodeMethod (data: string, chain: Chain, specVersion: BN): Decoded { + let args: AnyJson | null = null; + let method: Call | null = null; + + try { + if (specVersion.eqn(chain.specVersion)) { + method = chain.registry.createType('Call', data); + args = (method.toHuman() as { args: AnyJson }).args; + } else { + console.log(displayDecodeVersion('Outdated metadata to decode', chain, specVersion)); + } + } catch (error) { + console.error(`${displayDecodeVersion('Error decoding method', chain, specVersion)}:: ${(error as Error).message}`); + + args = null; + method = null; + } + + return { args, method }; +} + +function renderMethod (data: string, { args, method }: Decoded, t: TFunction): React.ReactNode { + if (!args || !method) { + return ( + + {t('method data')} + {data} + + ); + } + + return ( + <> + + {t('method')} + +
    + {method.section}.{method.method}{ + method.meta + ? `(${method.meta.args.map(({ name }) => name).join(', ')})` + : '' + } +
    {JSON.stringify(args, null, 2)}
    +
    + + + {method.meta && ( + + {t('info')} + +
    + {method.meta.docs.map((d) => d.toString().trim()).join(' ')} +
    + + + )} + + ); +} + +function mortalityAsString (era: ExtrinsicEra, hexBlockNumber: string, t: TFunction): string { + if (era.isImmortalEra) { + return t('immortal'); + } + + const blockNumber = bnToBn(hexBlockNumber); + const mortal = era.asMortalEra; + + return t('mortal, valid from {{birth}} to {{death}}', { + replace: { + birth: formatNumber(mortal.birth(blockNumber)), + death: formatNumber(mortal.death(blockNumber)) + } + }); +} + +function getHumanReadableAssetId (assetId: unknown): string | undefined { + try { + return convertMultilocationToUrl(assetId); + } catch (_) { + return undefined; + } +} + +function Extrinsic ({ className, payload, request: { blockNumber, genesisHash, method, specVersion: hexSpec }, url }: Props): React.ReactElement { + const { t } = useTranslation(); + const chain = useMetadata(genesisHash); + const specVersion = useRef(bnToBn(hexSpec)).current; + const decoded = useMemo( + () => chain && chain.hasMetadata + ? decodeMethod(method, chain, specVersion) + : { args: null, method: null }, + [method, chain, specVersion] + ); + + const humanReadablePayload = payload.toHuman() as Record; + const assetId = humanReadablePayload['assetId']; + const humanReadableAssetId = getHumanReadableAssetId(assetId); + + return ( + + + + + + + + + + + + + + + + + + {!payload.tip.isEmpty && ( + + + + + )} + {!!assetId && ( + + + + + )} + {renderMethod(method, decoded, t)} + + + + +
    {t('from')}{url}
    {chain ? t('chain') : t('genesis')}{chain ? chain.name : genesisHash}
    {t('version')}{specVersion.toNumber()}
    {t('nonce')}{formatNumber(payload.nonce)}
    {t('tip')}{formatNumber(payload.tip)}
    {t('assetId')} +
    + {humanReadableAssetId || '{...}'} +
    {JSON.stringify(assetId, null, 2)}
    +
    +
    {t('lifetime')}{mortalityAsString(payload.era, blockNumber, t)}
    + ); +} + +export default React.memo(Extrinsic); diff --git a/packages/extension-ui/src/Popup/Signing/LedgerSign.tsx b/packages/extension-ui/src/Popup/Signing/LedgerSign.tsx new file mode 100644 index 0000000..c464294 --- /dev/null +++ b/packages/extension-ui/src/Popup/Signing/LedgerSign.tsx @@ -0,0 +1,205 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/* eslint-disable deprecation/deprecation */ + +import type { Chain } from '@pezkuwi/extension-chains/types'; +import type { Ledger, LedgerGeneric } from '@pezkuwi/hw-ledger'; +import type { ExtrinsicPayload } from '@pezkuwi/types/interfaces'; +import type { SignerPayloadJSON } from '@pezkuwi/types/types'; +import type { HexString } from '@pezkuwi/util/types'; + +import { faSync } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import React, { useCallback, useEffect, useState } from 'react'; + +import settings from '@pezkuwi/ui-settings'; +import { assert, objectSpread, u8aToHex } from '@pezkuwi/util'; +import { merkleizeMetadata } from '@polkadot-api/merkleize-metadata'; + +import { Button, Warning } from '../../components/index.js'; +import { useLedger, useMetadata, useTranslation } from '../../hooks/index.js'; +import { styled } from '../../styled.js'; + +interface Props { + accountIndex?: number; + addressOffset?: number; + className?: string; + error: string | null; + genesisHash?: string; + isEthereum?: boolean; + onSignature?: ({ signature }: { signature: HexString }, signedTransaction?: HexString) => void; + payloadJson?: SignerPayloadJSON; + payloadExt?: ExtrinsicPayload + setError: (value: string | null) => void; +} + +function getMetadataProof (chain: Chain, payload: SignerPayloadJSON) { + const m = chain.definition.rawMetadata; + + assert(m, 'To sign with Ledger\'s Polkadot Generic App, the metadata must be present in the extension.'); + + const merkleizedMetadata = merkleizeMetadata(m, { + base58Prefix: chain.ss58Format, + decimals: chain.tokenDecimals, + specName: chain.name.toLowerCase(), + specVersion: chain.specVersion, + tokenSymbol: chain.tokenSymbol + }); + const metadataHash = u8aToHex(merkleizedMetadata.digest()); + const newPayload = objectSpread({}, payload, { metadataHash, mode: 1 }); + const raw = chain.registry.createType('ExtrinsicPayload', newPayload); + + return { + raw, + txMetadata: merkleizedMetadata.getProofForExtrinsicPayload(u8aToHex(raw.toU8a(true))) + }; +} + +function LedgerSign ({ accountIndex, addressOffset, className, error, genesisHash, isEthereum = false, onSignature, payloadExt, payloadJson, setError }: Props): React.ReactElement { + const [isBusy, setIsBusy] = useState(false); + const { t } = useTranslation(); + const chain = useMetadata(genesisHash); + const { error: ledgerError, isLoading: ledgerLoading, isLocked: ledgerLocked, ledger, refresh, type: _ledgerType, warning: ledgerWarning } = useLedger(genesisHash, accountIndex, addressOffset, isEthereum); + + useEffect(() => { + if (ledgerError) { + setError(ledgerError); + } + }, [chain, ledgerError, setError]); + + const _onRefresh = useCallback(() => { + refresh(); + setError(null); + }, [refresh, setError]); + + const _onSignLedger = useCallback( + (): void => { + if (!ledger || !payloadJson || !onSignature || !chain || !payloadExt) { + if (!chain) { + setError('No chain information found. You may need to update/upload the metadata.'); + setIsBusy(false); + } + + return; + } + + setError(null); + setIsBusy(true); + + const currApp = settings.get().ledgerApp; + + if (currApp === 'generic' || currApp === 'migration') { + if (!chain?.definition.rawMetadata) { + setError('No metadata found for this chain. You must upload the metadata to the extension in order to use Ledger.'); + } + + const { raw, txMetadata } = getMetadataProof(chain, payloadJson); + const metaBuff = Buffer.from(txMetadata); + + if (isEthereum) { + (ledger as LedgerGeneric).signWithMetadataEcdsa(raw.toU8a(true), accountIndex, addressOffset, { metadata: metaBuff }) + .then(({ signature }) => { + const extrinsic = chain.registry.createType( + 'Extrinsic', + { method: raw.method }, + { version: 4 } + ); + + (ledger as LedgerGeneric).getAddressEcdsa(false, accountIndex, addressOffset) + .then(({ address }) => { + extrinsic.addSignature(`0x${address}`, signature, raw.toHex()); + onSignature({ signature }, extrinsic.toHex()); + }) + .catch((e: Error) => { + setError(e.message); + setIsBusy(false); + }); + }).catch((e: Error) => { + setError(e.message); + setIsBusy(false); + }); + } else { + (ledger as LedgerGeneric).signWithMetadata(raw.toU8a(true), accountIndex, addressOffset, { metadata: metaBuff }) + .then((signature) => { + const extrinsic = chain.registry.createType( + 'Extrinsic', + { method: raw.method }, + { version: 4 } + ); + + (ledger as LedgerGeneric).getAddress(chain.ss58Format, false, accountIndex, addressOffset) + .then(({ address }) => { + extrinsic.addSignature(address, signature.signature, raw.toHex()); + onSignature(signature, extrinsic.toHex()); + }) + .catch((e: Error) => { + setError(e.message); + setIsBusy(false); + }); + }).catch((e: Error) => { + setError(e.message); + setIsBusy(false); + }); + } + } else if (currApp === 'chainSpecific') { + (ledger as Ledger).sign(payloadExt.toU8a(true), accountIndex, addressOffset) + .then((signature) => { + onSignature(signature); + }).catch((e: Error) => { + setError(e.message); + setIsBusy(false); + }); + } + }, + [accountIndex, addressOffset, chain, ledger, onSignature, payloadJson, payloadExt, setError, isEthereum] + ); + + return ( +
    + {!!ledgerWarning && ( + + {ledgerWarning} + + )} + {error && ( + + {error} + + )} + { + + {`You are using the Ledger ${settings.ledgerApp.toUpperCase()} App. If you would like to switch it, please go to "MANAGE LEDGER APP" in the extension's settings.`} + + } + {(ledgerLocked || error) + ? ( + + ) + : ( + + ) + } +
    + ); +} + +export default styled(LedgerSign) ` + flex-direction: column; + padding: 6px 24px; + + .danger { + margin-bottom: .5rem; + } +`; diff --git a/packages/extension-ui/src/Popup/Signing/Qr.tsx b/packages/extension-ui/src/Popup/Signing/Qr.tsx new file mode 100644 index 0000000..9dba9d6 --- /dev/null +++ b/packages/extension-ui/src/Popup/Signing/Qr.tsx @@ -0,0 +1,103 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { ExtrinsicPayload } from '@pezkuwi/types/interfaces'; +import type { HexString } from '@pezkuwi/util/types'; + +import React, { useCallback, useMemo, useState } from 'react'; + +import { QrDisplayPayload, QrScanSignature } from '@pezkuwi/react-qr'; +import { u8aWrapBytes } from '@pezkuwi/util'; + +import { Button } from '../../components/index.js'; +import { useTranslation } from '../../hooks/index.js'; +import { styled } from '../../styled.js'; +import { CMD_MORTAL, CMD_SIGN_MESSAGE } from './Request/index.js'; + +interface Props { + address: string; + children?: React.ReactNode; + className?: string; + cmd: number; + genesisHash: string; + onSignature: ({ signature }: { signature: HexString }) => void; + payload: ExtrinsicPayload | string; + +} + +function Qr ({ address, className, cmd, genesisHash, onSignature, payload }: Props): React.ReactElement { + const { t } = useTranslation(); + const [isScanning, setIsScanning] = useState(false); + + const payloadU8a = useMemo( + () => { + switch (cmd) { + case CMD_MORTAL: + return (payload as ExtrinsicPayload).toU8a(); + case CMD_SIGN_MESSAGE: + return u8aWrapBytes(payload as string); + default: + return null; + } + }, + [cmd, payload] + ); + + const _onShowQr = useCallback( + () => setIsScanning(true), + [] + ); + + if (!payloadU8a) { + return ( +
    +
    + Transaction command:{cmd} not supported. +
    +
    + ); + } + + return ( +
    +
    + {isScanning + ? + : ( + + ) + } +
    + {!isScanning && ( + + )} +
    + ); +} + +export default styled(Qr)` + height: 100%; + + .qrContainer { + margin: 5px auto 10px auto; + width: 65%; + + img { + border: white solid 1px; + } + } + + .scanButton { + margin-bottom: 8px; + } +`; diff --git a/packages/extension-ui/src/Popup/Signing/Request/SignArea.tsx b/packages/extension-ui/src/Popup/Signing/Request/SignArea.tsx new file mode 100644 index 0000000..e664941 --- /dev/null +++ b/packages/extension-ui/src/Popup/Signing/Request/SignArea.tsx @@ -0,0 +1,144 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import React, { useCallback, useContext, useEffect, useState } from 'react'; + +import { PASSWORD_EXPIRY_MIN } from '@pezkuwi/extension-base/defaults'; + +import { ActionBar, ActionContext, Button, ButtonArea, Checkbox, Link } from '../../../components/index.js'; +import { useTranslation } from '../../../hooks/index.js'; +import { approveSignPassword, cancelSignRequest, isSignLocked } from '../../../messaging.js'; +import { styled } from '../../../styled.js'; +import Unlock from '../Unlock.js'; + +interface Props { + buttonText: string; + className?: string; + error: string | null; + isExternal?: boolean; + isFirst: boolean; + setError: (value: string | null) => void; + signId: string; +} + +function SignArea ({ buttonText, className, error, isExternal, isFirst, setError, signId }: Props): React.ReactElement { + const [savePass, setSavePass] = useState(false); + const [isLocked, setIsLocked] = useState(null); + const [password, setPassword] = useState(''); + const [isBusy, setIsBusy] = useState(false); + const onAction = useContext(ActionContext); + const { t } = useTranslation(); + + useEffect(() => { + setIsLocked(null); + let timeout: ReturnType; + + !isExternal && isSignLocked(signId) + .then(({ isLocked, remainingTime }) => { + setIsLocked(isLocked); + timeout = setTimeout(() => { + setIsLocked(true); + }, remainingTime); + + !isLocked && setSavePass(true); + }) + .catch((error: Error) => console.error(error)); + + return () => { + !!timeout && clearTimeout(timeout); + }; + }, [isExternal, signId]); + + const _onSign = useCallback( + (): void => { + setIsBusy(true); + approveSignPassword(signId, savePass, password) + .then((): void => { + setIsBusy(false); + onAction(); + }) + .catch((error: Error): void => { + setIsBusy(false); + setError(error.message); + console.error(error); + }); + }, + [onAction, password, savePass, setError, setIsBusy, signId] + ); + + const _onCancel = useCallback( + (): void => { + cancelSignRequest(signId) + .then(() => onAction()) + .catch((error: Error) => console.error(error)); + }, + [onAction, signId] + ); + + const RememberPasswordCheckbox = () => ( + + ); + + return ( + + {isFirst && !isExternal && ( + <> + {isLocked && ( + + )} + + + + )} + + + {t('Cancel')} + + + + ); +} + +export default styled(SignArea) ` + flex-direction: column; + padding: 6px 24px; + + .cancelButton { + margin-top: 4px; + margin-bottom: 4px; + text-decoration: underline; + + a { + margin: auto; + } + } +`; diff --git a/packages/extension-ui/src/Popup/Signing/Request/index.tsx b/packages/extension-ui/src/Popup/Signing/Request/index.tsx new file mode 100644 index 0000000..e96fd97 --- /dev/null +++ b/packages/extension-ui/src/Popup/Signing/Request/index.tsx @@ -0,0 +1,197 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { AccountJson, RequestSign } from '@pezkuwi/extension-base/background/types'; +import type { ExtrinsicPayload } from '@pezkuwi/types/interfaces'; +import type { SignerPayloadJSON, SignerPayloadRaw } from '@pezkuwi/types/types'; +import type { HexString } from '@pezkuwi/util/types'; + +import React, { useCallback, useContext, useEffect, useState } from 'react'; + +import { TypeRegistry } from '@pezkuwi/types'; + +import { ActionContext, Address, VerticalSpace, Warning } from '../../../components/index.js'; +import { useMetadata, useTranslation } from '../../../hooks/index.js'; +import { approveSignSignature } from '../../../messaging.js'; +import Bytes from '../Bytes.js'; +import Extrinsic from '../Extrinsic.js'; +import LedgerSign from '../LedgerSign.js'; +import Qr from '../Qr.js'; +import SignArea from './SignArea.js'; + +interface Props { + account: AccountJson; + buttonText: string; + isFirst: boolean; + request: RequestSign; + signId: string; + url: string; +} + +interface Data { + hexBytes: string | null; + payload: ExtrinsicPayload | null; +} + +export const CMD_MORTAL = 2; +export const CMD_SIGN_MESSAGE = 3; + +// keep it global, we can and will re-use this across requests +const registry = new TypeRegistry(); + +function isRawPayload (payload: SignerPayloadJSON | SignerPayloadRaw): payload is SignerPayloadRaw { + return !!(payload as SignerPayloadRaw).data; +} + +export default function Request ({ account: { accountIndex, addressOffset, genesisHash, isExternal, isHardware, type }, buttonText, isFirst, request, signId, url }: Props): React.ReactElement | null { + const onAction = useContext(ActionContext); + const [{ hexBytes, payload }, setData] = useState({ hexBytes: null, payload: null }); + const [error, setError] = useState(null); + const { t } = useTranslation(); + const chain = useMetadata(genesisHash); + + useEffect((): void => { + // When the chain and request are ready, configure the chain's registry. + // This will be picked up by LedgerSign. + if (chain && !isRawPayload(request.payload)) { + chain.registry.setSignedExtensions(request.payload.signedExtensions, chain.definition.userExtensions); + } + }, [chain, request]); + + useEffect((): void => { + const payload = request.payload; + + if (isRawPayload(payload)) { + setData({ + hexBytes: payload.data, + payload: null + }); + } else { + registry.setSignedExtensions(payload.signedExtensions); + + setData({ + hexBytes: null, + payload: registry.createType('ExtrinsicPayload', payload, { version: payload.version }) + }); + } + }, [request]); + + const _onSignature = useCallback( + ({ signature }: { signature: HexString }, signedTransaction?: HexString): void => { + approveSignSignature(signId, signature, signedTransaction) + .then(() => onAction()) + .catch((error: Error): void => { + setError(error.message); + console.error(error); + }); + }, + [onAction, signId] + ); + + if (payload !== null) { + const json = request.payload as SignerPayloadJSON; + + return ( + <> +
    +
    +
    + {isExternal && !isHardware + ? ( + + ) + : ( + + ) + } + {isHardware && ( + + )} + + + ); + } else if (hexBytes !== null) { + const { address, data } = request.payload as SignerPayloadRaw; + + return ( + <> +
    +
    +
    + {isExternal && !isHardware && genesisHash + ? ( + + ) + : ( + + ) + } + + {isExternal && !isHardware && !genesisHash && ( + <> + {t('"Allow use on any network" is not supported to show a QR code. You must associate this account with a network.')} + + + )} + {isHardware && <> + {t('Message signing is not supported for hardware wallets.')} + + } + + + ); + } + + return null; +} diff --git a/packages/extension-ui/src/Popup/Signing/Signing.spec.tsx b/packages/extension-ui/src/Popup/Signing/Signing.spec.tsx new file mode 100644 index 0000000..b76f4e4 --- /dev/null +++ b/packages/extension-ui/src/Popup/Signing/Signing.spec.tsx @@ -0,0 +1,359 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import '@pezkuwi/extension-mocks/chrome'; + +import type { ReactWrapper } from 'enzyme'; +import type * as _ from '@pezkuwi/dev-test/globals.d.ts'; +import type { SigningRequest } from '@pezkuwi/extension-base/background/types'; + +import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; +import enzyme from 'enzyme'; +import { EventEmitter } from 'events'; +import React, { useState } from 'react'; +import { act } from 'react-dom/test-utils'; + +import { ActionContext, Address, Button, Input, SigningReqContext } from '../../components/index.js'; +import * as messaging from '../../messaging.js'; +import * as MetadataCache from '../../MetadataCache.js'; +import { flushAllPromises } from '../../testHelpers.js'; +import Request from './Request/index.js'; +import Extrinsic from './Extrinsic.js'; +import Signing from './index.js'; +import { westendMetadata } from './metadataMock.js'; +import Qr from './Qr.js'; +import TransactionIndex from './TransactionIndex.js'; + +const { configure, mount } = enzyme; + +// // NOTE Required for spyOn when using @swc/jest +// // https://github.com/swc-project/swc/issues/3843 +// jest.mock('../../messaging', (): Record => ({ +// __esModule: true, +// ...jest.requireActual('../../messaging') +// })); + +// jest.mock('../../MetadataCache', (): Record => ({ +// __esModule: true, +// ...jest.requireActual('../../MetadataCache') +// })); + +// For this file, there are a lot of them +/* eslint-disable @typescript-eslint/no-unsafe-argument */ + +// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-call +configure({ adapter: new Adapter() }); + +describe('Signing requests', () => { + let wrapper: ReactWrapper; + let onActionStub: ReturnType; + let signRequests: SigningRequest[] = []; + + const emitter = new EventEmitter(); + + function MockRequestsProvider (): React.ReactElement { + const [requests, setRequests] = useState(signRequests); + + emitter.on('request', setRequests); + + return ( + + + + ); + } + + const mountComponent = async (): Promise => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + wrapper = mount( + + + + ); + await act(flushAllPromises); + wrapper.update(); + }; + + const check = (input: ReactWrapper): unknown => input.simulate('change', { target: { checked: true } }); + + beforeEach(async () => { + jest.spyOn(messaging, 'cancelSignRequest').mockImplementation(() => Promise.resolve(true)); + jest.spyOn(messaging, 'approveSignPassword').mockImplementation(() => Promise.resolve(true)); + jest.spyOn(messaging, 'isSignLocked').mockImplementation(() => Promise.resolve({ isLocked: true, remainingTime: 0 })); + jest.spyOn(MetadataCache, 'getSavedMeta').mockImplementation(() => Promise.resolve(westendMetadata)); + + signRequests = [ + { + account: { + address: '5D4bqjQRPgdMBK8bNvhX4tSuCtSGZS7rZjD5XH5SoKcFeKn5', + genesisHash: null, + isHidden: false, + name: 'acc1', + parentAddress: '5Ggap6soAPaP5UeNaiJsgqQwdVhhNnm6ez7Ba1w9jJ62LM2Q', + suri: '//0', + whenCreated: 1602001346486 + }, + id: '1607347015530.2', + request: { + payload: { + address: '5D4bqjQRPgdMBK8bNvhX4tSuCtSGZS7rZjD5XH5SoKcFeKn5', + blockHash: '0x661f57d206d4fecda0408943427d4d25436518acbff543735e7569da9db6bdd7', + blockNumber: '0x0033fa6b', + era: '0xb502', + genesisHash: '0xe143f23803ac50e8f6f8e62695d1ce9e4e1d68aa36c1cd2cfd15340213f3423e', + method: '0x0403c6111b239376e5e8b983dc2d2459cbb6caed64cc1d21723973d061ae0861ef690b00b04e2bde6f', + nonce: '0x00000003', + signedExtensions: [ + 'CheckSpecVersion', + 'CheckTxVersion', + 'CheckGenesis', + 'CheckMortality', + 'CheckNonce', + 'CheckWeight', + 'ChargeTransactionPayment' + ], + specVersion: '0x0000002d', + tip: '0x00000000000000000000000000000000', + transactionVersion: '0x00000003', + version: 4 + }, + sign: jest.fn() + }, + url: 'https://js.pezkuwichain.app/apps/?rpc=wss%3A%2F%2Fwestend-rpc.polkadot.io#/accounts' + }, + { + account: { + address: '5Ggap6soAPaP5UeNaiJsgqQwdVhhNnm6ez7Ba1w9jJ62LM2Q', + genesisHash: null, + isHidden: false, + name: 'acc 2', + suri: '//0', + whenCreated: 1602001346486 + }, + id: '1607356155395.3', + request: { + payload: { + address: '5Ggap6soAPaP5UeNaiJsgqQwdVhhNnm6ez7Ba1w9jJ62LM2Q', + blockHash: '0xcf69b7935b785f90b22d2b36f2227132ef9c5dd33db1dbac9ecdafac05bf9476', + blockNumber: '0x0036269a', + era: '0xa501', + genesisHash: '0xe143f23803ac50e8f6f8e62695d1ce9e4e1d68aa36c1cd2cfd15340213f3423e', + method: '0x0400cc4e0e2848c488896dd0a24f153070e85e3c83f6199cfc942ab6de29c56c2d7b0700d0ed902e', + nonce: '0x00000003', + signedExtensions: [ + 'CheckSpecVersion', + 'CheckTxVersion', + 'CheckGenesis', + 'CheckMortality', + 'CheckNonce', + 'CheckWeight', + 'ChargeTransactionPayment' + ], + specVersion: '0x0000002d', + tip: '0x00000000000000000000000000000000', + transactionVersion: '0x00000003', + version: 4 + }, + sign: jest.fn() + }, + url: 'https://js.pezkuwichain.app/apps' + } + ]; + onActionStub = jest.fn(); + await mountComponent(); + }); + + describe('Switching between requests', () => { + it('initially first request should be shown', () => { + expect(wrapper.find(TransactionIndex).text()).toBe('1/2'); + expect(wrapper.find(Request).prop('signId')).toBe(signRequests[0].id); + }); + + it('only the right arrow should be active on first screen', async () => { + expect(wrapper.find('FontAwesomeIcon.arrowLeft')).toHaveLength(1); + expect(wrapper.find('FontAwesomeIcon.arrowLeft.active')).toHaveLength(0); + expect(wrapper.find('FontAwesomeIcon.arrowRight.active')).toHaveLength(1); + wrapper.find('FontAwesomeIcon.arrowLeft').simulate('click'); + await act(flushAllPromises); + + expect(wrapper.find(TransactionIndex).text()).toBe('1/2'); + }); + + it('should display second request after clicking right arrow', async () => { + wrapper.find('FontAwesomeIcon.arrowRight').simulate('click'); + await act(flushAllPromises); + + expect(wrapper.find(TransactionIndex).text()).toBe('2/2'); + expect(wrapper.find(Request).prop('signId')).toBe(signRequests[1].id); + }); + + it('only the left should be active on second screen', async () => { + wrapper.find('FontAwesomeIcon.arrowRight').simulate('click'); + await act(flushAllPromises); + + expect(wrapper.find('FontAwesomeIcon.arrowLeft.active')).toHaveLength(1); + expect(wrapper.find('FontAwesomeIcon.arrowRight')).toHaveLength(1); + expect(wrapper.find('FontAwesomeIcon.arrowRight.active')).toHaveLength(0); + expect(wrapper.find(TransactionIndex).text()).toBe('2/2'); + }); + + it('should display previous request after the left arrow has been clicked', async () => { + wrapper.find('FontAwesomeIcon.arrowRight').simulate('click'); + await act(flushAllPromises); + wrapper.find('FontAwesomeIcon.arrowLeft').simulate('click'); + await act(flushAllPromises); + + expect(wrapper.find(TransactionIndex).text()).toBe('1/2'); + expect(wrapper.find(Request).prop('signId')).toBe(signRequests[0].id); + }); + }); + + describe('External account', () => { + it('shows Qr scanner for external accounts', async () => { + signRequests = [{ + account: { + address: '5Cf1CGZas62RWwce3d2EPqUvSoi1txaXKd9M5w9bEFSsQtRe', + genesisHash: null, + isExternal: true, + isHidden: false, + name: 'Dave account on Signer ', + whenCreated: 1602085704296 + }, + id: '1607357806151.5', + request: { + payload: { + address: '5Cf1CGZas62RWwce3d2EPqUvSoi1txaXKd9M5w9bEFSsQtRe', + blockHash: '0xd2f2dfb56c16af1d0faf5b454153d3199aeb6647537f4161c26a34541c591ec8', + blockNumber: '0x00340171', + era: '0x1503', + genesisHash: '0xe143f23803ac50e8f6f8e62695d1ce9e4e1d68aa36c1cd2cfd15340213f3423e', + method: '0x0403c6111b239376e5e8b983dc2d2459cbb6caed64cc1d21723973d061ae0861ef690b00b04e2bde6f', + nonce: '0x00000000', + signedExtensions: [ + 'CheckSpecVersion', + 'CheckTxVersion', + 'CheckGenesis', + 'CheckMortality', + 'CheckNonce', + 'CheckWeight', + 'ChargeTransactionPayment' + ], + specVersion: '0x0000002d', + tip: '0x00000000000000000000000000000000', + transactionVersion: '0x00000003', + version: 4 + }, + sign: jest.fn() + }, + url: 'https://js.pezkuwichain.app/apps/?rpc=wss%3A%2F%2Fwestend-rpc.polkadot.io#/accounts' + }]; + await mountComponent(); + expect(wrapper.find(Extrinsic)).toHaveLength(0); + expect(wrapper.find(Qr)).toHaveLength(1); + }); + }); + + describe('Request rendering', () => { + it('correctly displays request 1', () => { + expect(wrapper.find(Address).find('.fullAddress').text()).toBe(signRequests[0].account.address); + expect(wrapper.find(Extrinsic).find('td.data').map((el): string => el.text())).toEqual([ + 'https://js.pezkuwichain.app/apps/?rpc=wss%3A%2F%2Fwestend-rpc.polkadot.io#/accounts', + 'Westend', + '45', + '3', + `balances.transferKeepAlive(dest, value){ + "dest": "5GYQRJj3NUznYDzCduENRcocMsyxmb6tjb5xW87ZMErBe9R7", + "value": "123.0000 WND" +}`, + 'Same as the [`transfer`] call, but with a check that the transfer will not kill the origin account.', + 'mortal, valid from {{birth}} to {{death}}' + ]); + }); + + it('correctly displays request 2', async () => { + wrapper.find('FontAwesomeIcon.arrowRight').simulate('click'); + await act(flushAllPromises); + + expect(wrapper.find(Address).find('.fullAddress').text()).toBe(signRequests[1].account.address); + expect(wrapper.find(Extrinsic).find('td.data').map((el): string => el.text())).toEqual([ + 'https://js.pezkuwichain.app/apps', + 'Westend', + '45', + '3', + `balances.transfer(dest, value){ + "dest": "5Ggap6soAPaP5UeNaiJsgqQwdVhhNnm6ez7Ba1w9jJ62LM2Q", + "value": "200.0000 mWND" +}`, + 'Transfer some liquid free balance to another account.', + 'mortal, valid from {{birth}} to {{death}}' + ]); + }); + }); + + describe('Submitting', () => { + it('passes request id to cancel call', async () => { + wrapper.find('.cancelButton').find('a').simulate('click'); + await act(flushAllPromises); + + expect(messaging.cancelSignRequest).toHaveBeenCalledWith(signRequests[0].id); + }); + + it('passes request id and password to approve call', async () => { + wrapper.find(Input).simulate('change', { target: { value: 'hunter1' } }); + await act(flushAllPromises); + + wrapper.find(Button).find('button').simulate('click'); + await act(flushAllPromises); + wrapper.update(); + + expect(messaging.approveSignPassword).toHaveBeenCalledWith(signRequests[0].id, false, 'hunter1'); + }); + + it('asks the background to cache the password when the relevant checkbox is checked', async () => { + check(wrapper.find('input[type="checkbox"]')); + await act(flushAllPromises); + + wrapper.find(Input).simulate('change', { target: { value: 'hunter1' } }); + await act(flushAllPromises); + + wrapper.find(Button).find('button').simulate('click'); + await act(flushAllPromises); + wrapper.update(); + + expect(messaging.approveSignPassword).toHaveBeenCalledWith(signRequests[0].id, true, 'hunter1'); + }); + + it('shows an error when the password is wrong', async () => { + // silencing the following expected console.error + console.error = jest.fn(); + // eslint-disable-next-line @typescript-eslint/require-await + jest.spyOn(messaging, 'approveSignPassword').mockImplementation(async () => { + throw new Error('Unable to decode using the supplied passphrase'); + }); + + wrapper.find(Input).simulate('change', { target: { value: 'anything' } }); + await act(flushAllPromises); + + wrapper.find(Button).find('button').simulate('click'); + await act(flushAllPromises); + wrapper.update(); + + expect(wrapper.find('.warning-message').first().text()).toBe('Unable to decode using the supplied passphrase'); + }); + + it('when last request has been removed/cancelled, shows the previous one', async () => { + wrapper.find('FontAwesomeIcon.arrowRight').simulate('click'); + await act(flushAllPromises); + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + act(() => { + emitter.emit('request', [signRequests[0]]); + }); + await act(flushAllPromises); + wrapper.update(); + + expect(wrapper.find(TransactionIndex)).toHaveLength(0); + expect(wrapper.find(Request).prop('signId')).toBe(signRequests[0].id); + }); + }); +}); diff --git a/packages/extension-ui/src/Popup/Signing/TransactionIndex.tsx b/packages/extension-ui/src/Popup/Signing/TransactionIndex.tsx new file mode 100644 index 0000000..a968f7a --- /dev/null +++ b/packages/extension-ui/src/Popup/Signing/TransactionIndex.tsx @@ -0,0 +1,93 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import { faArrowLeft, faArrowRight } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import React, { useCallback } from 'react'; + +import { styled } from '../../styled.js'; + +interface Props { + className?: string; + index: number; + totalItems: number; + onNextClick: () => void; + onPreviousClick: () => void; +} + +function TransactionIndex ({ className, index, onNextClick, onPreviousClick, totalItems }: Props): React.ReactElement { + const previousClickActive = index !== 0; + const nextClickActive = index < totalItems - 1; + + const prevClick = useCallback( + (): void => { + previousClickActive && onPreviousClick(); + }, + [onPreviousClick, previousClickActive] + ); + + const nextClick = useCallback( + (): void => { + nextClickActive && onNextClick(); + }, + [nextClickActive, onNextClick] + ); + + return ( +
    +
    + {index + 1} + /{totalItems} +
    +
    + + +
    +
    + ); +} + +export default styled(TransactionIndex)` + align-items: center; + display: flex; + justify-content: space-between; + flex-grow: 1; + padding-right: 24px; + + .arrowLeft, .arrowRight { + display: inline-block; + color: var(--iconNeutralColor); + + &.active { + color: var(--primaryColor); + cursor: pointer; + } + } + + .arrowRight { + margin-left: 0.5rem; + } + + .currentStep { + color: var(--primaryColor); + font-size: var(--labelFontSize); + line-height: var(--labelLineHeight); + margin-left: 10px; + } + + .totalSteps { + font-size: var(--labelFontSize); + line-height: var(--labelLineHeight); + color: var(--textColor); + } +`; diff --git a/packages/extension-ui/src/Popup/Signing/Unlock.tsx b/packages/extension-ui/src/Popup/Signing/Unlock.tsx new file mode 100644 index 0000000..509a0df --- /dev/null +++ b/packages/extension-ui/src/Popup/Signing/Unlock.tsx @@ -0,0 +1,55 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import React, { useCallback } from 'react'; + +import { InputWithLabel, Warning } from '../../components/index.js'; +import { useTranslation } from '../../hooks/index.js'; + +interface Props { + className?: string; + error?: string | null; + isBusy: boolean; + onSign: () => void; + password: string; + setError: (error: string | null) => void; + setPassword: (password: string) => void; +} + +function Unlock ({ className, error, isBusy, onSign, password, setError, setPassword }: Props): React.ReactElement { + const { t } = useTranslation(); + + const _onChangePassword = useCallback( + (password: string): void => { + setPassword(password); + setError(null); + }, + [setError, setPassword] + ); + + return ( +
    + + {error && ( + + {error} + + )} +
    + ); +} + +export default React.memo(Unlock); diff --git a/packages/extension-ui/src/Popup/Signing/index.tsx b/packages/extension-ui/src/Popup/Signing/index.tsx new file mode 100644 index 0000000..85923a4 --- /dev/null +++ b/packages/extension-ui/src/Popup/Signing/index.tsx @@ -0,0 +1,71 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { SignerPayloadJSON } from '@pezkuwi/types/types'; + +import React, { useCallback, useContext, useEffect, useState } from 'react'; + +import { Loading, SigningReqContext } from '../../components/index.js'; +import { useTranslation } from '../../hooks/index.js'; +import { Header } from '../../partials/index.js'; +import Request from './Request/index.js'; +import TransactionIndex from './TransactionIndex.js'; + +export default function Signing (): React.ReactElement { + const { t } = useTranslation(); + const requests = useContext(SigningReqContext); + const [requestIndex, setRequestIndex] = useState(0); + + const _onNextClick = useCallback( + () => setRequestIndex((requestIndex) => requestIndex + 1), + [] + ); + + const _onPreviousClick = useCallback( + () => setRequestIndex((requestIndex) => requestIndex - 1), + [] + ); + + useEffect(() => { + setRequestIndex( + (requestIndex) => requestIndex < requests.length + ? requestIndex + : requests.length - 1 + ); + }, [requests]); + + // protect against removal overflows/underflows + const request = requests.length !== 0 + ? requestIndex >= 0 + ? requestIndex < requests.length + ? requests[requestIndex] + : requests[requests.length - 1] + : requests[0] + : null; + const isTransaction = !!((request?.request?.payload as SignerPayloadJSON)?.blockNumber); + + return request + ? ( + <> +
    + {requests.length > 1 && ( + + )} +
    + + + ) + : ; +} diff --git a/packages/extension-ui/src/Popup/Signing/metadataMock.ts b/packages/extension-ui/src/Popup/Signing/metadataMock.ts new file mode 100644 index 0000000..5119e32 --- /dev/null +++ b/packages/extension-ui/src/Popup/Signing/metadataMock.ts @@ -0,0 +1,30 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { MetadataDef } from '@pezkuwi/extension-inject/types'; + +export const westendMetadata = { + chain: 'Westend', + color: '#da68a7', + genesisHash: '0xe143f23803ac50e8f6f8e62695d1ce9e4e1d68aa36c1cd2cfd15340213f3423e', + icon: 'polkadot', + metaCalls: 'bWV0YQxgGFN5c3RlbQABKChmaWxsX2Jsb2NrBBhfcmF0aW8cUGVyYmlsbAQFAUEgZGlzcGF0Y2ggdGhhdCB3aWxsIGZpbGwgdGhlIGJsb2NrIHdlaWdodCB1cCB0byB0aGUgZ2l2ZW4gcmF0aW8uGHJlbWFyawQcX3JlbWFyaxRCeXRlcwRoTWFrZSBzb21lIG9uLWNoYWluIHJlbWFyay44c2V0X2hlYXBfcGFnZXMEFHBhZ2VzDHU2NAT4U2V0IHRoZSBudW1iZXIgb2YgcGFnZXMgaW4gdGhlIFdlYkFzc2VtYmx5IGVudmlyb25tZW50J3MgaGVhcC4gc2V0X2NvZGUEEGNvZGUUQnl0ZXMEZFNldCB0aGUgbmV3IHJ1bnRpbWUgY29kZS5cc2V0X2NvZGVfd2l0aG91dF9jaGVja3MEEGNvZGUUQnl0ZXMEGQFTZXQgdGhlIG5ldyBydW50aW1lIGNvZGUgd2l0aG91dCBkb2luZyBhbnkgY2hlY2tzIG9mIHRoZSBnaXZlbiBgY29kZWAuXHNldF9jaGFuZ2VzX3RyaWVfY29uZmlnBExjaGFuZ2VzX3RyaWVfY29uZmlngE9wdGlvbjxDaGFuZ2VzVHJpZUNvbmZpZ3VyYXRpb24+BJxTZXQgdGhlIG5ldyBjaGFuZ2VzIHRyaWUgY29uZmlndXJhdGlvbi4sc2V0X3N0b3JhZ2UEFGl0ZW1zNFZlYzxLZXlWYWx1ZT4EaFNldCBzb21lIGl0ZW1zIG9mIHN0b3JhZ2UuMGtpbGxfc3RvcmFnZQQQa2V5cyBWZWM8S2V5PgR0S2lsbCBzb21lIGl0ZW1zIGZyb20gc3RvcmFnZS4sa2lsbF9wcmVmaXgIGHByZWZpeAxLZXkgX3N1YmtleXMMdTMyBBEBS2lsbCBhbGwgc3RvcmFnZSBpdGVtcyB3aXRoIGEga2V5IHRoYXQgc3RhcnRzIHdpdGggdGhlIGdpdmVuIHByZWZpeC4cc3VpY2lkZQAIYQFLaWxsIHRoZSBzZW5kaW5nIGFjY291bnQsIGFzc3VtaW5nIHRoZXJlIGFyZSBubyByZWZlcmVuY2VzIG91dHN0YW5kaW5nIGFuZCB0aGUgY29tcG9zaXRljGRhdGEgaXMgZXF1YWwgdG8gaXRzIGRlZmF1bHQgdmFsdWUuAAAAAGBSYW5kb21uZXNzQ29sbGVjdGl2ZUZsaXAAAAAAABkQQmFiZQABCExyZXBvcnRfZXF1aXZvY2F0aW9uCEhlcXVpdm9jYXRpb25fcHJvb2ZUQmFiZUVxdWl2b2NhdGlvblByb29mPGtleV9vd25lcl9wcm9vZjRLZXlPd25lclByb29mEAkBUmVwb3J0IGF1dGhvcml0eSBlcXVpdm9jYXRpb24vbWlzYmVoYXZpb3IuIFRoaXMgbWV0aG9kIHdpbGwgdmVyaWZ5BQF0aGUgZXF1aXZvY2F0aW9uIHByb29mIGFuZCB2YWxpZGF0ZSB0aGUgZ2l2ZW4ga2V5IG93bmVyc2hpcCBwcm9vZg0BYWdhaW5zdCB0aGUgZXh0cmFjdGVkIG9mZmVuZGVyLiBJZiBib3RoIGFyZSB2YWxpZCwgdGhlIG9mZmVuY2Ugd2lsbDBiZSByZXBvcnRlZC5wcmVwb3J0X2VxdWl2b2NhdGlvbl91bnNpZ25lZAhIZXF1aXZvY2F0aW9uX3Byb29mVEJhYmVFcXVpdm9jYXRpb25Qcm9vZjxrZXlfb3duZXJfcHJvb2Y0S2V5T3duZXJQcm9vZiAJAVJlcG9ydCBhdXRob3JpdHkgZXF1aXZvY2F0aW9uL21pc2JlaGF2aW9yLiBUaGlzIG1ldGhvZCB3aWxsIHZlcmlmeQUBdGhlIGVxdWl2b2NhdGlvbiBwcm9vZiBhbmQgdmFsaWRhdGUgdGhlIGdpdmVuIGtleSBvd25lcnNoaXAgcHJvb2YNAWFnYWluc3QgdGhlIGV4dHJhY3RlZCBvZmZlbmRlci4gSWYgYm90aCBhcmUgdmFsaWQsIHRoZSBvZmZlbmNlIHdpbGwwYmUgcmVwb3J0ZWQuDQFUaGlzIGV4dHJpbnNpYyBtdXN0IGJlIGNhbGxlZCB1bnNpZ25lZCBhbmQgaXQgaXMgZXhwZWN0ZWQgdGhhdCBvbmx5FQFibG9jayBhdXRob3JzIHdpbGwgY2FsbCBpdCAodmFsaWRhdGVkIGluIGBWYWxpZGF0ZVVuc2lnbmVkYCksIGFzIHN1Y2gVAWlmIHRoZSBibG9jayBhdXRob3IgaXMgZGVmaW5lZCBpdCB3aWxsIGJlIGRlZmluZWQgYXMgdGhlIGVxdWl2b2NhdGlvbiRyZXBvcnRlci4AAAABJFRpbWVzdGFtcAABBAxzZXQEDG5vdzxDb21wYWN0PE1vbWVudD4EVFNldCB0aGUgY3VycmVudCB0aW1lLgAAAAIcSW5kaWNlcwABFBRjbGFpbQQUaW5kZXgwQWNjb3VudEluZGV4BJhBc3NpZ24gYW4gcHJldmlvdXNseSB1bmFzc2lnbmVkIGluZGV4LiB0cmFuc2ZlcggMbmV3JEFjY291bnRJZBRpbmRleDBBY2NvdW50SW5kZXgIXQFBc3NpZ24gYW4gaW5kZXggYWxyZWFkeSBvd25lZCBieSB0aGUgc2VuZGVyIHRvIGFub3RoZXIgYWNjb3VudC4gVGhlIGJhbGFuY2UgcmVzZXJ2YXRpb264aXMgZWZmZWN0aXZlbHkgdHJhbnNmZXJyZWQgdG8gdGhlIG5ldyBhY2NvdW50LhBmcmVlBBRpbmRleDBBY2NvdW50SW5kZXgElEZyZWUgdXAgYW4gaW5kZXggb3duZWQgYnkgdGhlIHNlbmRlci44Zm9yY2VfdHJhbnNmZXIMDG5ldyRBY2NvdW50SWQUaW5kZXgwQWNjb3VudEluZGV4GGZyZWV6ZRBib29sCFUBRm9yY2UgYW4gaW5kZXggdG8gYW4gYWNjb3VudC4gVGhpcyBkb2Vzbid0IHJlcXVpcmUgYSBkZXBvc2l0LiBJZiB0aGUgaW5kZXggaXMgYWxyZWFkeehoZWxkLCB0aGVuIGFueSBkZXBvc2l0IGlzIHJlaW1idXJzZWQgdG8gaXRzIGN1cnJlbnQgb3duZXIuGGZyZWV6ZQQUaW5kZXgwQWNjb3VudEluZGV4BGUBRnJlZXplIGFuIGluZGV4IHNvIGl0IHdpbGwgYWx3YXlzIHBvaW50IHRvIHRoZSBzZW5kZXIgYWNjb3VudC4gVGhpcyBjb25zdW1lcyB0aGUgZGVwb3NpdC4AAAADIEJhbGFuY2VzAAEQIHRyYW5zZmVyCBBkZXN0MExvb2t1cFNvdXJjZRR2YWx1ZUBDb21wYWN0PEJhbGFuY2U+BNRUcmFuc2ZlciBzb21lIGxpcXVpZCBmcmVlIGJhbGFuY2UgdG8gYW5vdGhlciBhY2NvdW50LixzZXRfYmFsYW5jZQwMd2hvMExvb2t1cFNvdXJjZSBuZXdfZnJlZUBDb21wYWN0PEJhbGFuY2U+MG5ld19yZXNlcnZlZEBDb21wYWN0PEJhbGFuY2U+BJBTZXQgdGhlIGJhbGFuY2VzIG9mIGEgZ2l2ZW4gYWNjb3VudC44Zm9yY2VfdHJhbnNmZXIMGHNvdXJjZTBMb29rdXBTb3VyY2UQZGVzdDBMb29rdXBTb3VyY2UUdmFsdWVAQ29tcGFjdDxCYWxhbmNlPhhNAUV4YWN0bHkgYXMgYHRyYW5zZmVyYCwgZXhjZXB0IHRoZSBvcmlnaW4gbXVzdCBiZSByb290IGFuZCB0aGUgc291cmNlIGFjY291bnQgbWF5IGJlKHNwZWNpZmllZC4oIyA8d2VpZ2h0Pj0BLSBTYW1lIGFzIHRyYW5zZmVyLCBidXQgYWRkaXRpb25hbCByZWFkIGFuZCB3cml0ZSBiZWNhdXNlIHRoZSBzb3VyY2UgYWNjb3VudCBpc4Rub3QgYXNzdW1lZCB0byBiZSBpbiB0aGUgb3ZlcmxheS4sIyA8L3dlaWdodD5MdHJhbnNmZXJfa2VlcF9hbGl2ZQgQZGVzdDBMb29rdXBTb3VyY2UUdmFsdWVAQ29tcGFjdDxCYWxhbmNlPghNAVNhbWUgYXMgdGhlIFtgdHJhbnNmZXJgXSBjYWxsLCBidXQgd2l0aCBhIGNoZWNrIHRoYXQgdGhlIHRyYW5zZmVyIHdpbGwgbm90IGtpbGwgdGhlPG9yaWdpbiBhY2NvdW50LgAAAARIVHJhbnNhY3Rpb25QYXltZW50AAAAAAAaKEF1dGhvcnNoaXAAAQQoc2V0X3VuY2xlcwQobmV3X3VuY2xlcyxWZWM8SGVhZGVyPgRgUHJvdmlkZSBhIHNldCBvZiB1bmNsZXMuAAAABRxTdGFraW5nAAFgEGJvbmQMKGNvbnRyb2xsZXIwTG9va3VwU291cmNlFHZhbHVlSENvbXBhY3Q8QmFsYW5jZU9mPhRwYXllZURSZXdhcmREZXN0aW5hdGlvbghhAVRha2UgdGhlIG9yaWdpbiBhY2NvdW50IGFzIGEgc3Rhc2ggYW5kIGxvY2sgdXAgYHZhbHVlYCBvZiBpdHMgYmFsYW5jZS4gYGNvbnRyb2xsZXJgIHdpbGyAYmUgdGhlIGFjY291bnQgdGhhdCBjb250cm9scyBpdC4oYm9uZF9leHRyYQQ4bWF4X2FkZGl0aW9uYWxIQ29tcGFjdDxCYWxhbmNlT2Y+CGEBQWRkIHNvbWUgZXh0cmEgYW1vdW50IHRoYXQgaGF2ZSBhcHBlYXJlZCBpbiB0aGUgc3Rhc2ggYGZyZWVfYmFsYW5jZWAgaW50byB0aGUgYmFsYW5jZSB1cDBmb3Igc3Rha2luZy4YdW5ib25kBBR2YWx1ZUhDb21wYWN0PEJhbGFuY2VPZj4MUQFTY2hlZHVsZSBhIHBvcnRpb24gb2YgdGhlIHN0YXNoIHRvIGJlIHVubG9ja2VkIHJlYWR5IGZvciB0cmFuc2ZlciBvdXQgYWZ0ZXIgdGhlIGJvbmT8cGVyaW9kIGVuZHMuIElmIHRoaXMgbGVhdmVzIGFuIGFtb3VudCBhY3RpdmVseSBib25kZWQgbGVzcyB0aGFuIQFUOjpDdXJyZW5jeTo6bWluaW11bV9iYWxhbmNlKCksIHRoZW4gaXQgaXMgaW5jcmVhc2VkIHRvIHRoZSBmdWxsIGFtb3VudC5Ed2l0aGRyYXdfdW5ib25kZWQESG51bV9zbGFzaGluZ19zcGFucwx1MzIEKQFSZW1vdmUgYW55IHVubG9ja2VkIGNodW5rcyBmcm9tIHRoZSBgdW5sb2NraW5nYCBxdWV1ZSBmcm9tIG91ciBtYW5hZ2VtZW50LiB2YWxpZGF0ZQQUcHJlZnM4VmFsaWRhdG9yUHJlZnME5ERlY2xhcmUgdGhlIGRlc2lyZSB0byB2YWxpZGF0ZSBmb3IgdGhlIG9yaWdpbiBjb250cm9sbGVyLiBub21pbmF0ZQQcdGFyZ2V0c0RWZWM8TG9va3VwU291cmNlPgQNAURlY2xhcmUgdGhlIGRlc2lyZSB0byBub21pbmF0ZSBgdGFyZ2V0c2AgZm9yIHRoZSBvcmlnaW4gY29udHJvbGxlci4UY2hpbGwABMREZWNsYXJlIG5vIGRlc2lyZSB0byBlaXRoZXIgdmFsaWRhdGUgb3Igbm9taW5hdGUuJHNldF9wYXllZQQUcGF5ZWVEUmV3YXJkRGVzdGluYXRpb24EtChSZS0pc2V0IHRoZSBwYXltZW50IHRhcmdldCBmb3IgYSBjb250cm9sbGVyLjhzZXRfY29udHJvbGxlcgQoY29udHJvbGxlcjBMb29rdXBTb3VyY2UEjChSZS0pc2V0IHRoZSBjb250cm9sbGVyIG9mIGEgc3Rhc2guTHNldF92YWxpZGF0b3JfY291bnQEDG5ldzBDb21wYWN0PHUzMj4EkFNldHMgdGhlIGlkZWFsIG51bWJlciBvZiB2YWxpZGF0b3JzLmBpbmNyZWFzZV92YWxpZGF0b3JfY291bnQEKGFkZGl0aW9uYWwwQ29tcGFjdDx1MzI+BKhJbmNyZW1lbnRzIHRoZSBpZGVhbCBudW1iZXIgb2YgdmFsaWRhdG9ycy5Uc2NhbGVfdmFsaWRhdG9yX2NvdW50BBhmYWN0b3IcUGVyY2VudATQU2NhbGUgdXAgdGhlIGlkZWFsIG51bWJlciBvZiB2YWxpZGF0b3JzIGJ5IGEgZmFjdG9yLjRmb3JjZV9ub19lcmFzAASsRm9yY2UgdGhlcmUgdG8gYmUgbm8gbmV3IGVyYXMgaW5kZWZpbml0ZWx5LjRmb3JjZV9uZXdfZXJhAAhJAUZvcmNlIHRoZXJlIHRvIGJlIGEgbmV3IGVyYSBhdCB0aGUgZW5kIG9mIHRoZSBuZXh0IHNlc3Npb24uIEFmdGVyIHRoaXMsIGl0IHdpbGwgYmWccmVzZXQgdG8gbm9ybWFsIChub24tZm9yY2VkKSBiZWhhdmlvdXIuRHNldF9pbnZ1bG5lcmFibGVzBDRpbnZ1bG5lcmFibGVzOFZlYzxBY2NvdW50SWQ+BMhTZXQgdGhlIHZhbGlkYXRvcnMgd2hvIGNhbm5vdCBiZSBzbGFzaGVkIChpZiBhbnkpLjRmb3JjZV91bnN0YWtlCBRzdGFzaCRBY2NvdW50SWRIbnVtX3NsYXNoaW5nX3NwYW5zDHUzMgQJAUZvcmNlIGEgY3VycmVudCBzdGFrZXIgdG8gYmVjb21lIGNvbXBsZXRlbHkgdW5zdGFrZWQsIGltbWVkaWF0ZWx5LlBmb3JjZV9uZXdfZXJhX2Fsd2F5cwAEAQFGb3JjZSB0aGVyZSB0byBiZSBhIG5ldyBlcmEgYXQgdGhlIGVuZCBvZiBzZXNzaW9ucyBpbmRlZmluaXRlbHkuVGNhbmNlbF9kZWZlcnJlZF9zbGFzaAgMZXJhIEVyYUluZGV4NHNsYXNoX2luZGljZXMgVmVjPHUzMj4ElENhbmNlbCBlbmFjdG1lbnQgb2YgYSBkZWZlcnJlZCBzbGFzaC44cGF5b3V0X3N0YWtlcnMIPHZhbGlkYXRvcl9zdGFzaCRBY2NvdW50SWQMZXJhIEVyYUluZGV4BA0BUGF5IG91dCBhbGwgdGhlIHN0YWtlcnMgYmVoaW5kIGEgc2luZ2xlIHZhbGlkYXRvciBmb3IgYSBzaW5nbGUgZXJhLhhyZWJvbmQEFHZhbHVlSENvbXBhY3Q8QmFsYW5jZU9mPgTcUmVib25kIGEgcG9ydGlvbiBvZiB0aGUgc3Rhc2ggc2NoZWR1bGVkIHRvIGJlIHVubG9ja2VkLkRzZXRfaGlzdG9yeV9kZXB0aAhEbmV3X2hpc3RvcnlfZGVwdGhEQ29tcGFjdDxFcmFJbmRleD5IX2VyYV9pdGVtc19kZWxldGVkMENvbXBhY3Q8dTMyPggtAVNldCBgSGlzdG9yeURlcHRoYCB2YWx1ZS4gVGhpcyBmdW5jdGlvbiB3aWxsIGRlbGV0ZSBhbnkgaGlzdG9yeSBpbmZvcm1hdGlvbnx3aGVuIGBIaXN0b3J5RGVwdGhgIGlzIHJlZHVjZWQuKHJlYXBfc3Rhc2gIFHN0YXNoJEFjY291bnRJZEhudW1fc2xhc2hpbmdfc3BhbnMMdTMyDDUBUmVtb3ZlIGFsbCBkYXRhIHN0cnVjdHVyZSBjb25jZXJuaW5nIGEgc3Rha2VyL3N0YXNoIG9uY2UgaXRzIGJhbGFuY2UgaXMgemVyby5dAVRoaXMgaXMgZXNzZW50aWFsbHkgZXF1aXZhbGVudCB0byBgd2l0aGRyYXdfdW5ib25kZWRgIGV4Y2VwdCBpdCBjYW4gYmUgY2FsbGVkIGJ5IGFueW9uZbxhbmQgdGhlIHRhcmdldCBgc3Rhc2hgIG11c3QgaGF2ZSBubyBmdW5kcyBsZWZ0LmBzdWJtaXRfZWxlY3Rpb25fc29sdXRpb24UHHdpbm5lcnNMVmVjPFZhbGlkYXRvckluZGV4Phxjb21wYWN0SENvbXBhY3RBc3NpZ25tZW50cxRzY29yZTRFbGVjdGlvblNjb3JlDGVyYSBFcmFJbmRleBBzaXplMEVsZWN0aW9uU2l6ZQTgU3VibWl0IGFuIGVsZWN0aW9uIHJlc3VsdCB0byB0aGUgY2hhaW4uIElmIHRoZSBzb2x1dGlvbjqEc3VibWl0X2VsZWN0aW9uX3NvbHV0aW9uX3Vuc2lnbmVkFBx3aW5uZXJzTFZlYzxWYWxpZGF0b3JJbmRleD4cY29tcGFjdEhDb21wYWN0QXNzaWdubWVudHMUc2NvcmU0RWxlY3Rpb25TY29yZQxlcmEgRXJhSW5kZXgQc2l6ZTBFbGVjdGlvblNpemUEvFVuc2lnbmVkIHZlcnNpb24gb2YgYHN1Ym1pdF9lbGVjdGlvbl9zb2x1dGlvbmAuAAAABiBPZmZlbmNlcwABAAAAAAcoSGlzdG9yaWNhbAAAAAAAGxxTZXNzaW9uAAEIIHNldF9rZXlzCBBrZXlzEEtleXMUcHJvb2YUQnl0ZXMM5FNldHMgdGhlIHNlc3Npb24ga2V5KHMpIG9mIHRoZSBmdW5jdGlvbiBjYWxsZXIgdG8gYGtleXNgLh0BQWxsb3dzIGFuIGFjY291bnQgdG8gc2V0IGl0cyBzZXNzaW9uIGtleSBwcmlvciB0byBiZWNvbWluZyBhIHZhbGlkYXRvci7AVGhpcyBkb2Vzbid0IHRha2UgZWZmZWN0IHVudGlsIHRoZSBuZXh0IHNlc3Npb24uKHB1cmdlX2tleXMACMhSZW1vdmVzIGFueSBzZXNzaW9uIGtleShzKSBvZiB0aGUgZnVuY3Rpb24gY2FsbGVyLsBUaGlzIGRvZXNuJ3QgdGFrZSBlZmZlY3QgdW50aWwgdGhlIG5leHQgc2Vzc2lvbi4AAAAIPEZpbmFsaXR5VHJhY2tlcgABBChmaW5hbF9oaW50BBBoaW50UENvbXBhY3Q8QmxvY2tOdW1iZXI+CPBIaW50IHRoYXQgdGhlIGF1dGhvciBvZiB0aGlzIGJsb2NrIHRoaW5rcyB0aGUgYmVzdCBmaW5hbGl6ZWRoYmxvY2sgaXMgdGhlIGdpdmVuIG51bWJlci4AAAAJHEdyYW5kcGEAAQxMcmVwb3J0X2VxdWl2b2NhdGlvbghIZXF1aXZvY2F0aW9uX3Byb29mYEdyYW5kcGFFcXVpdm9jYXRpb25Qcm9vZjxrZXlfb3duZXJfcHJvb2Y0S2V5T3duZXJQcm9vZhAJAVJlcG9ydCB2b3RlciBlcXVpdm9jYXRpb24vbWlzYmVoYXZpb3IuIFRoaXMgbWV0aG9kIHdpbGwgdmVyaWZ5IHRoZfRlcXVpdm9jYXRpb24gcHJvb2YgYW5kIHZhbGlkYXRlIHRoZSBnaXZlbiBrZXkgb3duZXJzaGlwIHByb29m+GFnYWluc3QgdGhlIGV4dHJhY3RlZCBvZmZlbmRlci4gSWYgYm90aCBhcmUgdmFsaWQsIHRoZSBvZmZlbmNlRHdpbGwgYmUgcmVwb3J0ZWQucHJlcG9ydF9lcXVpdm9jYXRpb25fdW5zaWduZWQISGVxdWl2b2NhdGlvbl9wcm9vZmBHcmFuZHBhRXF1aXZvY2F0aW9uUHJvb2Y8a2V5X293bmVyX3Byb29mNEtleU93bmVyUHJvb2YQCQFSZXBvcnQgdm90ZXIgZXF1aXZvY2F0aW9uL21pc2JlaGF2aW9yLiBUaGlzIG1ldGhvZCB3aWxsIHZlcmlmeSB0aGX0ZXF1aXZvY2F0aW9uIHByb29mIGFuZCB2YWxpZGF0ZSB0aGUgZ2l2ZW4ga2V5IG93bmVyc2hpcCBwcm9vZvhhZ2FpbnN0IHRoZSBleHRyYWN0ZWQgb2ZmZW5kZXIuIElmIGJvdGggYXJlIHZhbGlkLCB0aGUgb2ZmZW5jZUR3aWxsIGJlIHJlcG9ydGVkLjBub3RlX3N0YWxsZWQIFGRlbGF5LEJsb2NrTnVtYmVybGJlc3RfZmluYWxpemVkX2Jsb2NrX251bWJlcixCbG9ja051bWJlchwZAU5vdGUgdGhhdCB0aGUgY3VycmVudCBhdXRob3JpdHkgc2V0IG9mIHRoZSBHUkFORFBBIGZpbmFsaXR5IGdhZGdldCBoYXMlAXN0YWxsZWQuIFRoaXMgd2lsbCB0cmlnZ2VyIGEgZm9yY2VkIGF1dGhvcml0eSBzZXQgY2hhbmdlIGF0IHRoZSBiZWdpbm5pbmcdAW9mIHRoZSBuZXh0IHNlc3Npb24sIHRvIGJlIGVuYWN0ZWQgYGRlbGF5YCBibG9ja3MgYWZ0ZXIgdGhhdC4gVGhlIGRlbGF5EQFzaG91bGQgYmUgaGlnaCBlbm91Z2ggdG8gc2FmZWx5IGFzc3VtZSB0aGF0IHRoZSBibG9jayBzaWduYWxsaW5nIHRoZSUBZm9yY2VkIGNoYW5nZSB3aWxsIG5vdCBiZSByZS1vcmdlZCAoZS5nLiAxMDAwIGJsb2NrcykuIFRoZSBHUkFORFBBIHZvdGVycyUBd2lsbCBzdGFydCB0aGUgbmV3IGF1dGhvcml0eSBzZXQgdXNpbmcgdGhlIGdpdmVuIGZpbmFsaXplZCBibG9jayBhcyBiYXNlLlhPbmx5IGNhbGxhYmxlIGJ5IHJvb3QuAAAACiBJbU9ubGluZQABBCRoZWFydGJlYXQIJGhlYXJ0YmVhdCRIZWFydGJlYXQoX3NpZ25hdHVyZSRTaWduYXR1cmUkKCMgPHdlaWdodD49AS0gQ29tcGxleGl0eTogYE8oSyArIEUpYCB3aGVyZSBLIGlzIGxlbmd0aCBvZiBgS2V5c2AgKGhlYXJ0YmVhdC52YWxpZGF0b3JzX2xlbin0YW5kIEUgaXMgbGVuZ3RoIG9mIGBoZWFydGJlYXQubmV0d29ya19zdGF0ZS5leHRlcm5hbF9hZGRyZXNzYIAtIGBPKEspYDogZGVjb2Rpbmcgb2YgbGVuZ3RoIGBLYKQtIGBPKEUpYDogZGVjb2RpbmcvZW5jb2Rpbmcgb2YgbGVuZ3RoIGBFYDkBLSBEYlJlYWRzOiBwYWxsZXRfc2Vzc2lvbiBgVmFsaWRhdG9yc2AsIHBhbGxldF9zZXNzaW9uIGBDdXJyZW50SW5kZXhgLCBgS2V5c2AsUGBSZWNlaXZlZEhlYXJ0YmVhdHNggC0gRGJXcml0ZXM6IGBSZWNlaXZlZEhlYXJ0YmVhdHNgLCMgPC93ZWlnaHQ+AAAAC0hBdXRob3JpdHlEaXNjb3ZlcnkAAQAAAAAMHFV0aWxpdHkAAQgUYmF0Y2gEFGNhbGxzJFZlYzxDYWxsPgR8U2VuZCBhIGJhdGNoIG9mIGRpc3BhdGNoIGNhbGxzLjRhc19kZXJpdmF0aXZlCBRpbmRleAx1MTYQY2FsbBBDYWxsBNxTZW5kIGEgY2FsbCB0aHJvdWdoIGFuIGluZGV4ZWQgcHNldWRvbnltIG9mIHRoZSBzZW5kZXIuAAAAECBJZGVudGl0eQABPDRhZGRfcmVnaXN0cmFyBBxhY2NvdW50JEFjY291bnRJZAR4QWRkIGEgcmVnaXN0cmFyIHRvIHRoZSBzeXN0ZW0uMHNldF9pZGVudGl0eQQQaW5mbzBJZGVudGl0eUluZm8EKQFTZXQgYW4gYWNjb3VudCdzIGlkZW50aXR5IGluZm9ybWF0aW9uIGFuZCByZXNlcnZlIHRoZSBhcHByb3ByaWF0ZSBkZXBvc2l0LiBzZXRfc3VicwQQc3Vic1RWZWM8KEFjY291bnRJZCxEYXRhKT4EjFNldCB0aGUgc3ViLWFjY291bnRzIG9mIHRoZSBzZW5kZXIuOGNsZWFyX2lkZW50aXR5AAQ5AUNsZWFyIGFuIGFjY291bnQncyBpZGVudGl0eSBpbmZvIGFuZCBhbGwgc3ViLWFjY291bnRzIGFuZCByZXR1cm4gYWxsIGRlcG9zaXRzLkRyZXF1ZXN0X2p1ZGdlbWVudAgkcmVnX2luZGV4XENvbXBhY3Q8UmVnaXN0cmFySW5kZXg+HG1heF9mZWVIQ29tcGFjdDxCYWxhbmNlT2Y+BJRSZXF1ZXN0IGEganVkZ2VtZW50IGZyb20gYSByZWdpc3RyYXIuOGNhbmNlbF9yZXF1ZXN0BCRyZWdfaW5kZXg4UmVnaXN0cmFySW5kZXgEaENhbmNlbCBhIHByZXZpb3VzIHJlcXVlc3QuHHNldF9mZWUIFGluZGV4XENvbXBhY3Q8UmVnaXN0cmFySW5kZXg+DGZlZUhDb21wYWN0PEJhbGFuY2VPZj4EGQFTZXQgdGhlIGZlZSByZXF1aXJlZCBmb3IgYSBqdWRnZW1lbnQgdG8gYmUgcmVxdWVzdGVkIGZyb20gYSByZWdpc3RyYXIuOHNldF9hY2NvdW50X2lkCBRpbmRleFxDb21wYWN0PFJlZ2lzdHJhckluZGV4PgxuZXckQWNjb3VudElkBLxDaGFuZ2UgdGhlIGFjY291bnQgYXNzb2NpYXRlZCB3aXRoIGEgcmVnaXN0cmFyLihzZXRfZmllbGRzCBRpbmRleFxDb21wYWN0PFJlZ2lzdHJhckluZGV4PhhmaWVsZHM4SWRlbnRpdHlGaWVsZHMEqFNldCB0aGUgZmllbGQgaW5mb3JtYXRpb24gZm9yIGEgcmVnaXN0cmFyLkRwcm92aWRlX2p1ZGdlbWVudAwkcmVnX2luZGV4XENvbXBhY3Q8UmVnaXN0cmFySW5kZXg+GHRhcmdldDBMb29rdXBTb3VyY2UkanVkZ2VtZW50RElkZW50aXR5SnVkZ2VtZW50BLhQcm92aWRlIGEganVkZ2VtZW50IGZvciBhbiBhY2NvdW50J3MgaWRlbnRpdHkuNGtpbGxfaWRlbnRpdHkEGHRhcmdldDBMb29rdXBTb3VyY2UEQQFSZW1vdmUgYW4gYWNjb3VudCdzIGlkZW50aXR5IGFuZCBzdWItYWNjb3VudCBpbmZvcm1hdGlvbiBhbmQgc2xhc2ggdGhlIGRlcG9zaXRzLhxhZGRfc3ViCAxzdWIwTG9va3VwU291cmNlEGRhdGEQRGF0YQSsQWRkIHRoZSBnaXZlbiBhY2NvdW50IHRvIHRoZSBzZW5kZXIncyBzdWJzLihyZW5hbWVfc3ViCAxzdWIwTG9va3VwU291cmNlEGRhdGEQRGF0YQTMQWx0ZXIgdGhlIGFzc29jaWF0ZWQgbmFtZSBvZiB0aGUgZ2l2ZW4gc3ViLWFjY291bnQuKHJlbW92ZV9zdWIEDHN1YjBMb29rdXBTb3VyY2UEwFJlbW92ZSB0aGUgZ2l2ZW4gYWNjb3VudCBmcm9tIHRoZSBzZW5kZXIncyBzdWJzLiBxdWl0X3N1YgAEjFJlbW92ZSB0aGUgc2VuZGVyIGFzIGEgc3ViLWFjY291bnQuAAAAESBSZWNvdmVyeQABJDBhc19yZWNvdmVyZWQIHGFjY291bnQkQWNjb3VudElkEGNhbGwQQ2FsbASgU2VuZCBhIGNhbGwgdGhyb3VnaCBhIHJlY292ZXJlZCBhY2NvdW50LjRzZXRfcmVjb3ZlcmVkCBBsb3N0JEFjY291bnRJZBxyZXNjdWVyJEFjY291bnRJZAgZAUFsbG93IFJPT1QgdG8gYnlwYXNzIHRoZSByZWNvdmVyeSBwcm9jZXNzIGFuZCBzZXQgYW4gYSByZXNjdWVyIGFjY291bnRwZm9yIGEgbG9zdCBhY2NvdW50IGRpcmVjdGx5LjxjcmVhdGVfcmVjb3ZlcnkMHGZyaWVuZHM4VmVjPEFjY291bnRJZD4kdGhyZXNob2xkDHUxNjBkZWxheV9wZXJpb2QsQmxvY2tOdW1iZXIEWQFDcmVhdGUgYSByZWNvdmVyeSBjb25maWd1cmF0aW9uIGZvciB5b3VyIGFjY291bnQuIFRoaXMgbWFrZXMgeW91ciBhY2NvdW50IHJlY292ZXJhYmxlLkRpbml0aWF0ZV9yZWNvdmVyeQQcYWNjb3VudCRBY2NvdW50SWQE6EluaXRpYXRlIHRoZSBwcm9jZXNzIGZvciByZWNvdmVyaW5nIGEgcmVjb3ZlcmFibGUgYWNjb3VudC44dm91Y2hfcmVjb3ZlcnkIEGxvc3QkQWNjb3VudElkHHJlc2N1ZXIkQWNjb3VudElkCCUBQWxsb3cgYSAiZnJpZW5kIiBvZiBhIHJlY292ZXJhYmxlIGFjY291bnQgdG8gdm91Y2ggZm9yIGFuIGFjdGl2ZSByZWNvdmVyeWRwcm9jZXNzIGZvciB0aGF0IGFjY291bnQuOGNsYWltX3JlY292ZXJ5BBxhY2NvdW50JEFjY291bnRJZATwQWxsb3cgYSBzdWNjZXNzZnVsIHJlc2N1ZXIgdG8gY2xhaW0gdGhlaXIgcmVjb3ZlcmVkIGFjY291bnQuOGNsb3NlX3JlY292ZXJ5BBxyZXNjdWVyJEFjY291bnRJZAgRAUFzIHRoZSBjb250cm9sbGVyIG9mIGEgcmVjb3ZlcmFibGUgYWNjb3VudCwgY2xvc2UgYW4gYWN0aXZlIHJlY292ZXJ5ZHByb2Nlc3MgZm9yIHlvdXIgYWNjb3VudC48cmVtb3ZlX3JlY292ZXJ5AARZAVJlbW92ZSB0aGUgcmVjb3ZlcnkgcHJvY2VzcyBmb3IgeW91ciBhY2NvdW50LiBSZWNvdmVyZWQgYWNjb3VudHMgYXJlIHN0aWxsIGFjY2Vzc2libGUuQGNhbmNlbF9yZWNvdmVyZWQEHGFjY291bnQkQWNjb3VudElkBNxDYW5jZWwgdGhlIGFiaWxpdHkgdG8gdXNlIGBhc19yZWNvdmVyZWRgIGZvciBgYWNjb3VudGAuAAAAEhxWZXN0aW5nAAEQEHZlc3QABLhVbmxvY2sgYW55IHZlc3RlZCBmdW5kcyBvZiB0aGUgc2VuZGVyIGFjY291bnQuKHZlc3Rfb3RoZXIEGHRhcmdldDBMb29rdXBTb3VyY2UEuFVubG9jayBhbnkgdmVzdGVkIGZ1bmRzIG9mIGEgYHRhcmdldGAgYWNjb3VudC48dmVzdGVkX3RyYW5zZmVyCBh0YXJnZXQwTG9va3VwU291cmNlIHNjaGVkdWxlLFZlc3RpbmdJbmZvBGRDcmVhdGUgYSB2ZXN0ZWQgdHJhbnNmZXIuVGZvcmNlX3Zlc3RlZF90cmFuc2ZlcgwYc291cmNlMExvb2t1cFNvdXJjZRh0YXJnZXQwTG9va3VwU291cmNlIHNjaGVkdWxlLFZlc3RpbmdJbmZvBGBGb3JjZSBhIHZlc3RlZCB0cmFuc2Zlci4AAAATJFNjaGVkdWxlcgABGCBzY2hlZHVsZRAQd2hlbixCbG9ja051bWJlcjhtYXliZV9wZXJpb2RpYzhPcHRpb248UGVyaW9kPiBwcmlvcml0eSBQcmlvcml0eRBjYWxsEENhbGwEcEFub255bW91c2x5IHNjaGVkdWxlIGEgdGFzay4YY2FuY2VsCBB3aGVuLEJsb2NrTnVtYmVyFGluZGV4DHUzMgSUQ2FuY2VsIGFuIGFub255bW91c2x5IHNjaGVkdWxlZCB0YXNrLjhzY2hlZHVsZV9uYW1lZBQIaWQUQnl0ZXMQd2hlbixCbG9ja051bWJlcjhtYXliZV9wZXJpb2RpYzhPcHRpb248UGVyaW9kPiBwcmlvcml0eSBQcmlvcml0eRBjYWxsEENhbGwEWFNjaGVkdWxlIGEgbmFtZWQgdGFzay4wY2FuY2VsX25hbWVkBAhpZBRCeXRlcwR4Q2FuY2VsIGEgbmFtZWQgc2NoZWR1bGVkIHRhc2suOHNjaGVkdWxlX2FmdGVyEBRhZnRlcixCbG9ja051bWJlcjhtYXliZV9wZXJpb2RpYzhPcHRpb248UGVyaW9kPiBwcmlvcml0eSBQcmlvcml0eRBjYWxsEENhbGwEqEFub255bW91c2x5IHNjaGVkdWxlIGEgdGFzayBhZnRlciBhIGRlbGF5LlBzY2hlZHVsZV9uYW1lZF9hZnRlchQIaWQUQnl0ZXMUYWZ0ZXIsQmxvY2tOdW1iZXI4bWF5YmVfcGVyaW9kaWM4T3B0aW9uPFBlcmlvZD4gcHJpb3JpdHkgUHJpb3JpdHkQY2FsbBBDYWxsBJBTY2hlZHVsZSBhIG5hbWVkIHRhc2sgYWZ0ZXIgYSBkZWxheS4AAAAUEFN1ZG8AARAQc3VkbwQQY2FsbBBDYWxsBDUBQXV0aGVudGljYXRlcyB0aGUgc3VkbyBrZXkgYW5kIGRpc3BhdGNoZXMgYSBmdW5jdGlvbiBjYWxsIHdpdGggYFJvb3RgIG9yaWdpbi5Uc3Vkb191bmNoZWNrZWRfd2VpZ2h0CBBjYWxsEENhbGwcX3dlaWdodBhXZWlnaHQMNQFBdXRoZW50aWNhdGVzIHRoZSBzdWRvIGtleSBhbmQgZGlzcGF0Y2hlcyBhIGZ1bmN0aW9uIGNhbGwgd2l0aCBgUm9vdGAgb3JpZ2luLi0BVGhpcyBmdW5jdGlvbiBkb2VzIG5vdCBjaGVjayB0aGUgd2VpZ2h0IG9mIHRoZSBjYWxsLCBhbmQgaW5zdGVhZCBhbGxvd3MgdGhlsFN1ZG8gdXNlciB0byBzcGVjaWZ5IHRoZSB3ZWlnaHQgb2YgdGhlIGNhbGwuHHNldF9rZXkEDG5ldzBMb29rdXBTb3VyY2UEcQFBdXRoZW50aWNhdGVzIHRoZSBjdXJyZW50IHN1ZG8ga2V5IGFuZCBzZXRzIHRoZSBnaXZlbiBBY2NvdW50SWQgKGBuZXdgKSBhcyB0aGUgbmV3IHN1ZG8ga2V5LhxzdWRvX2FzCAx3aG8wTG9va3VwU291cmNlEGNhbGwQQ2FsbAhNAUF1dGhlbnRpY2F0ZXMgdGhlIHN1ZG8ga2V5IGFuZCBkaXNwYXRjaGVzIGEgZnVuY3Rpb24gY2FsbCB3aXRoIGBTaWduZWRgIG9yaWdpbiBmcm9tQGEgZ2l2ZW4gYWNjb3VudC4AAAAVFFByb3h5AAEoFHByb3h5DBByZWFsJEFjY291bnRJZEBmb3JjZV9wcm94eV90eXBlRE9wdGlvbjxQcm94eVR5cGU+EGNhbGwQQ2FsbAhNAURpc3BhdGNoIHRoZSBnaXZlbiBgY2FsbGAgZnJvbSBhbiBhY2NvdW50IHRoYXQgdGhlIHNlbmRlciBpcyBhdXRob3Jpc2VkIGZvciB0aHJvdWdoMGBhZGRfcHJveHlgLiRhZGRfcHJveHkMIGRlbGVnYXRlJEFjY291bnRJZChwcm94eV90eXBlJFByb3h5VHlwZRRkZWxheSxCbG9ja051bWJlcgRFAVJlZ2lzdGVyIGEgcHJveHkgYWNjb3VudCBmb3IgdGhlIHNlbmRlciB0aGF0IGlzIGFibGUgdG8gbWFrZSBjYWxscyBvbiBpdHMgYmVoYWxmLjByZW1vdmVfcHJveHkMIGRlbGVnYXRlJEFjY291bnRJZChwcm94eV90eXBlJFByb3h5VHlwZRRkZWxheSxCbG9ja051bWJlcgSoVW5yZWdpc3RlciBhIHByb3h5IGFjY291bnQgZm9yIHRoZSBzZW5kZXIuOHJlbW92ZV9wcm94aWVzAAS0VW5yZWdpc3RlciBhbGwgcHJveHkgYWNjb3VudHMgZm9yIHRoZSBzZW5kZXIuJGFub255bW91cwwocHJveHlfdHlwZSRQcm94eVR5cGUUZGVsYXksQmxvY2tOdW1iZXIUaW5kZXgMdTE2CDkBU3Bhd24gYSBmcmVzaCBuZXcgYWNjb3VudCB0aGF0IGlzIGd1YXJhbnRlZWQgdG8gYmUgb3RoZXJ3aXNlIGluYWNjZXNzaWJsZSwgYW5k/GluaXRpYWxpemUgaXQgd2l0aCBhIHByb3h5IG9mIGBwcm94eV90eXBlYCBmb3IgYG9yaWdpbmAgc2VuZGVyLjhraWxsX2Fub255bW91cxQcc3Bhd25lciRBY2NvdW50SWQocHJveHlfdHlwZSRQcm94eVR5cGUUaW5kZXgMdTE2GGhlaWdodFBDb21wYWN0PEJsb2NrTnVtYmVyPiRleHRfaW5kZXgwQ29tcGFjdDx1MzI+BLRSZW1vdmVzIGEgcHJldmlvdXNseSBzcGF3bmVkIGFub255bW91cyBwcm94eS4gYW5ub3VuY2UIEHJlYWwkQWNjb3VudElkJGNhbGxfaGFzaChDYWxsSGFzaE9mBAUBUHVibGlzaCB0aGUgaGFzaCBvZiBhIHByb3h5LWNhbGwgdGhhdCB3aWxsIGJlIG1hZGUgaW4gdGhlIGZ1dHVyZS5McmVtb3ZlX2Fubm91bmNlbWVudAgQcmVhbCRBY2NvdW50SWQkY2FsbF9oYXNoKENhbGxIYXNoT2YEcFJlbW92ZSBhIGdpdmVuIGFubm91bmNlbWVudC5McmVqZWN0X2Fubm91bmNlbWVudAggZGVsZWdhdGUkQWNjb3VudElkJGNhbGxfaGFzaChDYWxsSGFzaE9mBLBSZW1vdmUgdGhlIGdpdmVuIGFubm91bmNlbWVudCBvZiBhIGRlbGVnYXRlLjxwcm94eV9hbm5vdW5jZWQQIGRlbGVnYXRlJEFjY291bnRJZBByZWFsJEFjY291bnRJZEBmb3JjZV9wcm94eV90eXBlRE9wdGlvbjxQcm94eVR5cGU+EGNhbGwQQ2FsbAhNAURpc3BhdGNoIHRoZSBnaXZlbiBgY2FsbGAgZnJvbSBhbiBhY2NvdW50IHRoYXQgdGhlIHNlbmRlciBpcyBhdXRob3Jpc2VkIGZvciB0aHJvdWdoMGBhZGRfcHJveHlgLgAAABYgTXVsdGlzaWcAARBQYXNfbXVsdGlfdGhyZXNob2xkXzEIRG90aGVyX3NpZ25hdG9yaWVzOFZlYzxBY2NvdW50SWQ+EGNhbGwQQ2FsbARRAUltbWVkaWF0ZWx5IGRpc3BhdGNoIGEgbXVsdGktc2lnbmF0dXJlIGNhbGwgdXNpbmcgYSBzaW5nbGUgYXBwcm92YWwgZnJvbSB0aGUgY2FsbGVyLiBhc19tdWx0aRgkdGhyZXNob2xkDHUxNkRvdGhlcl9zaWduYXRvcmllczhWZWM8QWNjb3VudElkPjxtYXliZV90aW1lcG9pbnRET3B0aW9uPFRpbWVwb2ludD4QY2FsbChPcGFxdWVDYWxsKHN0b3JlX2NhbGwQYm9vbChtYXhfd2VpZ2h0GFdlaWdodAhVAVJlZ2lzdGVyIGFwcHJvdmFsIGZvciBhIGRpc3BhdGNoIHRvIGJlIG1hZGUgZnJvbSBhIGRldGVybWluaXN0aWMgY29tcG9zaXRlIGFjY291bnQgaWb4YXBwcm92ZWQgYnkgYSB0b3RhbCBvZiBgdGhyZXNob2xkIC0gMWAgb2YgYG90aGVyX3NpZ25hdG9yaWVzYC5AYXBwcm92ZV9hc19tdWx0aRQkdGhyZXNob2xkDHUxNkRvdGhlcl9zaWduYXRvcmllczhWZWM8QWNjb3VudElkPjxtYXliZV90aW1lcG9pbnRET3B0aW9uPFRpbWVwb2ludD4kY2FsbF9oYXNoHFt1ODszMl0obWF4X3dlaWdodBhXZWlnaHQIVQFSZWdpc3RlciBhcHByb3ZhbCBmb3IgYSBkaXNwYXRjaCB0byBiZSBtYWRlIGZyb20gYSBkZXRlcm1pbmlzdGljIGNvbXBvc2l0ZSBhY2NvdW50IGlm+GFwcHJvdmVkIGJ5IGEgdG90YWwgb2YgYHRocmVzaG9sZCAtIDFgIG9mIGBvdGhlcl9zaWduYXRvcmllc2AuPGNhbmNlbF9hc19tdWx0aRAkdGhyZXNob2xkDHUxNkRvdGhlcl9zaWduYXRvcmllczhWZWM8QWNjb3VudElkPiR0aW1lcG9pbnQkVGltZXBvaW50JGNhbGxfaGFzaBxbdTg7MzJdCFUBQ2FuY2VsIGEgcHJlLWV4aXN0aW5nLCBvbi1nb2luZyBtdWx0aXNpZyB0cmFuc2FjdGlvbi4gQW55IGRlcG9zaXQgcmVzZXJ2ZWQgcHJldmlvdXNsecRmb3IgdGhpcyBvcGVyYXRpb24gd2lsbCBiZSB1bnJlc2VydmVkIG9uIHN1Y2Nlc3MuAAAAFwQcQENoZWNrU3BlY1ZlcnNpb244Q2hlY2tUeFZlcnNpb24wQ2hlY2tHZW5lc2lzOENoZWNrTW9ydGFsaXR5KENoZWNrTm9uY2UsQ2hlY2tXZWlnaHRgQ2hhcmdlVHJhbnNhY3Rpb25QYXltZW50', + specVersion: 45, + ss58Format: 42, + tokenDecimals: 12, + tokenSymbol: 'WND', + types: { + Address: 'AccountId', + Keys: 'SessionKeys5', + LookupSource: 'AccountId', + ProxyType: { + _enum: [ + 'Any', + 'NonTransfer', + 'Staking', + 'Unused', + 'IdentityJudgement' + ] as unknown + } + } +} as MetadataDef; diff --git a/packages/extension-ui/src/Popup/Welcome.tsx b/packages/extension-ui/src/Popup/Welcome.tsx new file mode 100644 index 0000000..3059ca5 --- /dev/null +++ b/packages/extension-ui/src/Popup/Welcome.tsx @@ -0,0 +1,55 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import React, { useCallback, useContext } from 'react'; + +import { ActionContext, Box, Button, ButtonArea, List, VerticalSpace } from '../components/index.js'; +import { useTranslation } from '../hooks/index.js'; +import { Header } from '../partials/index.js'; +import { styled } from '../styled.js'; + +interface Props { + className?: string; +} + +function Welcome ({ className }: Props): React.ReactElement { + const { t } = useTranslation(); + const onAction = useContext(ActionContext); + + const _onClick = useCallback( + (): void => { + window.localStorage.setItem('welcome_read', 'ok'); + onAction(); + }, + [onAction] + ); + + return ( + <> +
    +
    +

    {t('Before we start, just a couple of notes regarding use:')}

    + + +
  • {t('We do not send any clicks, pageviews or events to a central server')}
  • +
  • {t('We do not use any trackers or analytics')}
  • +
  • {t("We don't collect keys, addresses or any information - your information never leaves this machine")}
  • +
    +
    +

    {t('... we are not in the information collection business (even anonymized).')}

    +
    + + + + + + ); +} + +export default styled(Welcome)` + p { + color: var(--subTextColor); + margin-bottom: 6px; + margin-top: 0; + } +`; diff --git a/packages/extension-ui/src/Popup/index.tsx b/packages/extension-ui/src/Popup/index.tsx new file mode 100644 index 0000000..051f15e --- /dev/null +++ b/packages/extension-ui/src/Popup/index.tsx @@ -0,0 +1,189 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { AccountJson, AccountsContext, AuthorizeRequest, MetadataRequest, SigningRequest } from '@pezkuwi/extension-base/background/types'; +import type { SettingsStruct } from '@pezkuwi/ui-settings/types'; + +import React, { useCallback, useEffect, useState } from 'react'; +import { Route, Switch, useHistory } from 'react-router'; + +import { PHISHING_PAGE_REDIRECT } from '@pezkuwi/extension-base/defaults'; +import { canDerive } from '@pezkuwi/extension-base/utils'; +import { settings } from '@pezkuwi/ui-settings'; + +import { AccountContext, ActionContext, AuthorizeReqContext, MediaContext, MetadataReqContext, SettingsContext, SigningReqContext } from '../components/contexts.js'; +import { ErrorBoundary, Loading } from '../components/index.js'; +import ToastProvider from '../components/Toast/ToastProvider.js'; +import { ping, subscribeAccounts, subscribeAuthorizeRequests, subscribeMetadataRequests, subscribeSigningRequests } from '../messaging.js'; +import { buildHierarchy } from '../util/buildHierarchy.js'; +import Accounts from './Accounts/index.js'; +import AccountManagement from './AuthManagement/AccountManagement.js'; +import AuthList from './AuthManagement/index.js'; +import Authorize from './Authorize/index.js'; +import CreateAccount from './CreateAccount/index.js'; +import Derive from './Derive/index.js'; +import ImportSeed from './ImportSeed/index.js'; +import Metadata from './Metadata/index.js'; +import Signing from './Signing/index.js'; +import AssetHubMigration from './AssetHubMigration.js'; +import Export from './Export.js'; +import ExportAll from './ExportAll.js'; +import Forget from './Forget.js'; +import ImportLedger from './ImportLedger.js'; +import ImportQr from './ImportQr.js'; +import PhishingDetected from './PhishingDetected.js'; +import RestoreJson from './RestoreJson.js'; +import Welcome from './Welcome.js'; + +const startSettings = settings.get(); + +// Request permission for video, based on access we can hide/show import +async function requestMediaAccess (cameraOn: boolean): Promise { + if (!cameraOn) { + return false; + } + + try { + await navigator.mediaDevices.getUserMedia({ video: true }); + + return true; + } catch (error) { + console.error('Permission for video declined', (error as Error).message); + } + + return false; +} + +function initAccountContext ({ accounts, selectedAccounts, setSelectedAccounts }: Omit): AccountsContext { + const hierarchy = buildHierarchy(accounts); + const master = hierarchy.find(({ isExternal, type }) => !isExternal && canDerive(type)); + + return { + accounts, + hierarchy, + master, + selectedAccounts, + setSelectedAccounts + }; +} + +export default function Popup (): React.ReactElement { + const [accounts, setAccounts] = useState(null); + const [accountCtx, setAccountCtx] = useState({ accounts: [], hierarchy: [] }); + const [selectedAccounts, setSelectedAccounts] = useState([]); + const [authRequests, setAuthRequests] = useState(null); + const [cameraOn, setCameraOn] = useState(startSettings.camera === 'on'); + const [mediaAllowed, setMediaAllowed] = useState(false); + const [metaRequests, setMetaRequests] = useState(null); + const [signRequests, setSignRequests] = useState(null); + const [isWelcomeDone, setWelcomeDone] = useState(false); + const [isMigrationDone, setMigrationDone] = useState(false); + const [settingsCtx, setSettingsCtx] = useState(startSettings); + const history = useHistory(); + + const _onAction = useCallback( + (to?: string): void => { + setWelcomeDone(window.localStorage.getItem('welcome_read') === 'ok'); + setMigrationDone(window.localStorage.getItem('asset_hub_migration_read') === 'ok'); + + if (!to) { + return; + } + + to === '../index.js' + // if we can't go gack from there, go to the home + ? history.length === 1 + ? history.push('/') + : history.goBack() + : window.location.hash = to; + }, + [history] + ); + + useEffect((): void => { + // initially send a ping message to create a port that will be reused for subsequent + // messages. This ensure onConnect event is fired only once + ping().then(() => Promise.all([ + subscribeAccounts(setAccounts), + subscribeAuthorizeRequests(setAuthRequests), + subscribeMetadataRequests(setMetaRequests), + subscribeSigningRequests(setSignRequests) + ])).catch(console.error); + + settings.on('change', (settings): void => { + setSettingsCtx(settings); + setCameraOn(settings.camera === 'on'); + }); + + _onAction(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect((): void => { + setAccountCtx(initAccountContext({ accounts: accounts || [], selectedAccounts, setSelectedAccounts })); + }, [accounts, selectedAccounts]); + + useEffect((): void => { + requestMediaAccess(cameraOn) + .then(setMediaAllowed) + .catch(console.error); + }, [cameraOn]); + + function wrapWithErrorBoundary (component: React.ReactElement, trigger?: string): React.ReactElement { + return {component}; + } + + const Root = !isWelcomeDone + ? wrapWithErrorBoundary(, 'welcome') + : !isMigrationDone + ? wrapWithErrorBoundary(, 'asset-hub-migration') + : authRequests?.length + ? wrapWithErrorBoundary(, 'authorize') + : metaRequests?.length + ? wrapWithErrorBoundary(, 'metadata') + : signRequests?.length + ? wrapWithErrorBoundary(, 'signing') + : wrapWithErrorBoundary(, 'accounts'); + + return ( + {accounts && authRequests && metaRequests && signRequests && ( + + + + + + + + + + {wrapWithErrorBoundary(, 'auth-list')} + {wrapWithErrorBoundary(, 'account-creation')} + {wrapWithErrorBoundary(, 'forget-address')} + {wrapWithErrorBoundary(, 'export-address')} + {wrapWithErrorBoundary(, 'export-all-address')} + {wrapWithErrorBoundary(, 'import-ledger')} + {wrapWithErrorBoundary(, 'import-qr')} + {wrapWithErrorBoundary(, 'import-seed')} + {wrapWithErrorBoundary(, 'restore-json')} + {wrapWithErrorBoundary(, 'derived-address-locked')} + {wrapWithErrorBoundary(, 'derive-address')} + {wrapWithErrorBoundary(, 'manage-url')} + {wrapWithErrorBoundary(, 'phishing-page-redirect')} + + {Root} + + + + + + + + + + + )} + ); +} diff --git a/packages/extension-ui/src/adapter.d.ts b/packages/extension-ui/src/adapter.d.ts new file mode 100644 index 0000000..10a11ca --- /dev/null +++ b/packages/extension-ui/src/adapter.d.ts @@ -0,0 +1,4 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +declare module '@wojtekmaj/enzyme-adapter-react-17'; diff --git a/packages/extension-ui/src/assets/arrow-down.svg b/packages/extension-ui/src/assets/arrow-down.svg new file mode 100644 index 0000000..494dd40 --- /dev/null +++ b/packages/extension-ui/src/assets/arrow-down.svg @@ -0,0 +1 @@ + diff --git a/packages/extension-ui/src/assets/checkmark.svg b/packages/extension-ui/src/assets/checkmark.svg new file mode 100644 index 0000000..3996dc0 --- /dev/null +++ b/packages/extension-ui/src/assets/checkmark.svg @@ -0,0 +1 @@ + diff --git a/packages/extension-ui/src/assets/details.svg b/packages/extension-ui/src/assets/details.svg new file mode 100644 index 0000000..fa1bbe6 --- /dev/null +++ b/packages/extension-ui/src/assets/details.svg @@ -0,0 +1 @@ + diff --git a/packages/extension-ui/src/assets/images.d.ts b/packages/extension-ui/src/assets/images.d.ts new file mode 100644 index 0000000..35ac0c2 --- /dev/null +++ b/packages/extension-ui/src/assets/images.d.ts @@ -0,0 +1,22 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +declare module '*.svg' { + const url: string; + export default url; +} + +declare module '*.png' { + const url: string; + export default url; +} + +declare module '*.woff' { + const url: string; + export default url; +} + +declare module '*.woff2' { + const url: string; + export default url; +} diff --git a/packages/extension-ui/src/assets/pjs.svg b/packages/extension-ui/src/assets/pjs.svg new file mode 100644 index 0000000..aa816d6 --- /dev/null +++ b/packages/extension-ui/src/assets/pjs.svg @@ -0,0 +1 @@ + diff --git a/packages/extension-ui/src/assets/spinner-white.png b/packages/extension-ui/src/assets/spinner-white.png new file mode 100644 index 0000000..1b93d39 Binary files /dev/null and b/packages/extension-ui/src/assets/spinner-white.png differ diff --git a/packages/extension-ui/src/assets/spinner.png b/packages/extension-ui/src/assets/spinner.png new file mode 100644 index 0000000..6d0aee0 Binary files /dev/null and b/packages/extension-ui/src/assets/spinner.png differ diff --git a/packages/extension-ui/src/components/AccountNamePasswordCreation.spec.tsx b/packages/extension-ui/src/components/AccountNamePasswordCreation.spec.tsx new file mode 100644 index 0000000..56d2851 --- /dev/null +++ b/packages/extension-ui/src/components/AccountNamePasswordCreation.spec.tsx @@ -0,0 +1,204 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import '@pezkuwi/extension-mocks/chrome'; + +import type { ReactWrapper } from 'enzyme'; +import type * as _ from '@pezkuwi/dev-test/globals.d.ts'; + +import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; +import enzyme from 'enzyme'; +import React from 'react'; +import { act } from 'react-dom/test-utils'; + +import { flushAllPromises } from '../testHelpers.js'; +import BackButton from './BackButton.js'; +import { AccountNamePasswordCreation, Input, InputWithLabel, NextStepButton } from './index.js'; + +// For this file, there are a lot of them +/* eslint-disable @typescript-eslint/no-unsafe-argument */ + +const { configure, mount } = enzyme; + +// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-call +configure({ adapter: new Adapter() }); + +const account = { + name: 'My Polkadot Account', + password: 'somepassword' +}; + +const buttonLabel = 'create account'; + +let wrapper: ReactWrapper; +const onBackClick = jest.fn(); +const onCreate = jest.fn(); +const onNameChange = jest.fn(); + +const type = async (input: ReactWrapper, value: string): Promise => { + input.simulate('change', { target: { value } }); + await act(flushAllPromises); + wrapper.update(); +}; + +const capsLockOn = async (input: ReactWrapper): Promise => { + input.simulate('keyPress', { getModifierState: () => true }); + await act(flushAllPromises); + wrapper.update(); +}; + +const enterName = (name: string): Promise => type(wrapper.find('input').first(), name); +const password = (password: string) => (): Promise => type(wrapper.find('input[type="password"]').first(), password); +const repeat = (password: string) => (): Promise => type(wrapper.find('input[type="password"]').last(), password); + +// eslint-disable-next-line @typescript-eslint/no-unsafe-return +const mountComponent = (isBusy = false): ReactWrapper => mount( + +); + +describe('AccountNamePasswordCreation', () => { + beforeEach(async () => { + wrapper = mountComponent(); + await act(flushAllPromises); + wrapper.update(); + }); + + it('only account name input is visible at first', () => { + expect(wrapper.find(InputWithLabel).find('[data-input-name]').find(Input)).toHaveLength(1); + expect(wrapper.find(InputWithLabel).find('[data-input-password]').find(Input)).toHaveLength(1); + expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]')).toHaveLength(0); + expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true); + }); + + it('next step button has the correct label', () => { + expect(wrapper.find(NextStepButton).text()).toBe(buttonLabel); + }); + + it('back button calls onBackClick', () => { + wrapper.find(BackButton).simulate('click'); + expect(onBackClick).toHaveBeenCalledTimes(1); + }); + + it('input should not be highlighted as error until first interaction', () => { + expect(wrapper.find(InputWithLabel).find('[data-input-name]').find(Input).prop('withError')).toBe(false); + }); + + it('after typing less than 3 characters into name input, password input is not visible', async () => { + await enterName('ab'); + expect(wrapper.find(InputWithLabel).find('[data-input-name]').find(Input).prop('withError')).toBe(true); + expect(wrapper.find('.warning-message').first().text()).toBe('Account name is too short'); + expect(wrapper.find(InputWithLabel).find('[data-input-password]').find(Input)).toHaveLength(1); + expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]')).toHaveLength(0); + expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true); + }); + + it('input should keep showing error when something has been typed but then erased', async () => { + await enterName(account.name); + await enterName(''); + expect(wrapper.find(InputWithLabel).find('[data-input-name]').find(Input).prop('withError')).toBe(true); + }); + + it('after typing 3 characters into name input, onNameChange is called', async () => { + await enterName(account.name); + expect(onNameChange).toHaveBeenLastCalledWith(account.name); + }); + + it('after typing 3 characters into name input, first password input is visible', async () => { + await enterName(account.name); + expect(wrapper.find(InputWithLabel).find('[data-input-name]').find(Input).first().prop('withError')).toBe(false); + expect(wrapper.find(InputWithLabel).find('[data-input-password]').find(Input)).toHaveLength(1); + expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]')).toHaveLength(0); + expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true); + }); + + it('password with caps lock should show a warning', async () => { + await enterName('abc').then(password('abcde')); + await capsLockOn(wrapper.find(InputWithLabel).find('[data-input-password]').find(Input)); + + expect(wrapper.find('.warning-message').first().text()).toBe('Warning: Caps lock is on'); + }); + + it('password shorter than 6 characters should be not valid', async () => { + await enterName('abc').then(password('abcde')); + expect(wrapper.find(InputWithLabel).find('[data-input-password]').find(Input).prop('withError')).toBe(true); + expect(wrapper.find('.warning-message').text()).toBe('Password is too short'); + expect(wrapper.find(InputWithLabel).find('[data-input-password]').find(Input)).toHaveLength(1); + expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]')).toHaveLength(0); + expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true); + }); + + it('submit button is not enabled until both passwords are equal', async () => { + await enterName('abc').then(password('abcdef')).then(repeat('abcdeg')); + expect(wrapper.find('.warning-message').text()).toBe('Passwords do not match'); + expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]').find(Input).prop('withError')).toBe(true); + expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true); + }); + + it('submit button is enabled when both passwords are equal', async () => { + await enterName('abc').then(password('abcdef')).then(repeat('abcdef')); + expect(wrapper.find('.warning-message')).toHaveLength(0); + expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]').find(Input).prop('withError')).toBe(false); + expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(false); + }); + + it('calls onCreate with provided name and password', async () => { + await enterName(account.name).then(password(account.password)).then(repeat(account.password)); + wrapper.find('[data-button-action="add new root"] button').simulate('click'); + await act(flushAllPromises); + + expect(onCreate).toHaveBeenCalledWith(account.name, account.password); + }); + + describe('All fields are filled correctly, but then', () => { + beforeEach(async () => { + await enterName(account.name).then(password(account.password)).then(repeat(account.password)); + }); + + it('first password input is cleared - second one disappears and button get disabled', async () => { + await type(wrapper.find('input[type="password"]').first(), ''); + expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]')).toHaveLength(0); + expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true); + }); + + it('first password changes - button is disabled', async () => { + await type(wrapper.find('input[type="password"]').first(), 'abcdef'); + expect(wrapper.find('.warning-message').text()).toBe('Passwords do not match'); + expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true); + }); + + it('first password changes, then second changes too - button is enabled', async () => { + await type(wrapper.find('input[type="password"]').first(), 'abcdef'); + await type(wrapper.find('input[type="password"]').last(), 'abcdef'); + expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(false); + }); + + it('second password changes, then first changes too - button is enabled', async () => { + await type(wrapper.find('input[type="password"]').last(), 'abcdef'); + await type(wrapper.find('input[type="password"]').first(), 'abcdef'); + expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(false); + }); + + it('name is removed - button is disabled', async () => { + await enterName(''); + expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true); + }); + }); +}); + +describe('AccountNamePasswordCreation busy button', () => { + beforeAll(async () => { + wrapper = mountComponent(true); + await act(flushAllPromises); + wrapper.update(); + }); + + it('button is busy', () => { + expect(wrapper.find(NextStepButton).prop('isBusy')).toBe(true); + }); +}); diff --git a/packages/extension-ui/src/components/AccountNamePasswordCreation.tsx b/packages/extension-ui/src/components/AccountNamePasswordCreation.tsx new file mode 100644 index 0000000..b8c7ee2 --- /dev/null +++ b/packages/extension-ui/src/components/AccountNamePasswordCreation.tsx @@ -0,0 +1,83 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import React, { useCallback, useState } from 'react'; + +import { Name, Password } from '../partials/index.js'; +import { BackButton, ButtonArea, NextStepButton, VerticalSpace } from './index.js'; + +interface Props { + buttonLabel?: string; + isBusy: boolean; + onBackClick?: () => void; + onCreate: (name: string, password: string) => void | Promise; + onNameChange: (name: string) => void; + onPasswordChange?: (password: string) => void; +} + +function AccountNamePasswordCreation ({ buttonLabel, isBusy, onBackClick, onCreate, onNameChange, onPasswordChange }: Props): React.ReactElement { + const [name, setName] = useState(null); + const [password, setPassword] = useState(null); + + const _onCreate = useCallback( + (): void => { + if (name && password) { + Promise + .resolve(onCreate(name, password)) + .catch(console.error); + } + }, + [name, password, onCreate] + ); + + const _onNameChange = useCallback( + (name: string | null) => { + onNameChange(name || ''); + setName(name); + }, + [onNameChange] + ); + + const _onPasswordChange = useCallback( + (password: string | null) => { + onPasswordChange && onPasswordChange(password || ''); + setPassword(password); + }, + [onPasswordChange] + ); + + const _onBackClick = useCallback( + () => { + _onNameChange(null); + setPassword(null); + onBackClick && onBackClick(); + }, + [_onNameChange, onBackClick] + ); + + return ( + <> + + + + {onBackClick && buttonLabel && ( + + + + {buttonLabel} + + + )} + + ); +} + +export default React.memo(AccountNamePasswordCreation); diff --git a/packages/extension-ui/src/components/ActionBar.tsx b/packages/extension-ui/src/components/ActionBar.tsx new file mode 100644 index 0000000..24147f6 --- /dev/null +++ b/packages/extension-ui/src/components/ActionBar.tsx @@ -0,0 +1,35 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import React from 'react'; + +import { styled } from '../styled.js'; + +interface Props { + children: React.ReactNode; + className?: string; +} + +function ActionBar ({ children, className }: Props): React.ReactElement { + return ( +
    + {children} +
    + ); +} + +export default styled(ActionBar)` + align-content: flex-end; + display: flex; + justify-content: space-between; + padding: 0.25rem; + text-align: right; + + a { + cursor: pointer; + } + + a+a { + margin-left: 0.75rem; + } +`; diff --git a/packages/extension-ui/src/components/ActionText.tsx b/packages/extension-ui/src/components/ActionText.tsx new file mode 100644 index 0000000..484c404 --- /dev/null +++ b/packages/extension-ui/src/components/ActionText.tsx @@ -0,0 +1,48 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { IconDefinition } from '@fortawesome/fontawesome-svg-core'; +import type { MouseEventHandler } from 'react'; + +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import React from 'react'; + +import { styled } from '../styled.js'; + +interface Props { + className?: string; + icon?: IconDefinition; + onClick: MouseEventHandler; + text: string; +} + +function ActionText ({ className, icon, onClick, text }: Props): React.ReactElement { + return ( +
    + {icon && } + {text} +
    + ); +} + +export default styled(ActionText)` + cursor: pointer; + + span { + color: var(--labelColor); + font-size: var(--labelFontSize); + line-height: var(--labelLineHeight); + text-decoration-line: underline; + } + + .svg-inline--fa { + color: var(--iconNeutralColor); + display: inline-block; + margin-right: 0.3rem; + position: relative; + top: 2px; + } +`; diff --git a/packages/extension-ui/src/components/Address.spec.tsx b/packages/extension-ui/src/components/Address.spec.tsx new file mode 100644 index 0000000..155a1bd --- /dev/null +++ b/packages/extension-ui/src/components/Address.spec.tsx @@ -0,0 +1,351 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import '@pezkuwi/extension-mocks/chrome'; + +import type { ReactWrapper } from 'enzyme'; +import type * as _ from '@pezkuwi/dev-test/globals.d.ts'; +import type { AccountJson } from '@pezkuwi/extension-base/background/types'; +import type { IconTheme } from '@pezkuwi/react-identicon/types'; +import type { HexString } from '@pezkuwi/util/types'; +import type { Props as AddressComponentProps } from './Address.js'; + +import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; +import enzyme from 'enzyme'; +import React from 'react'; +import { act } from 'react-dom/test-utils'; + +import * as messaging from '../messaging.js'; +import * as MetadataCache from '../MetadataCache.js'; +import { westendMetadata } from '../Popup/Signing/metadataMock.js'; +import { flushAllPromises } from '../testHelpers.js'; +import { buildHierarchy } from '../util/buildHierarchy.js'; +import { DEFAULT_TYPE } from '../util/defaultType.js'; +import getParentNameSuri from '../util/getParentNameSuri.js'; +import { AccountContext, Address } from './index.js'; + +const { configure, mount } = enzyme; + +// NOTE Required for spyOn when using @swc/jest +// https://github.com/swc-project/swc/issues/3843 +// jest.mock('../messaging', (): Record => ({ +// __esModule: true, +// ...jest.requireActual('../messaging') +// })); + +// jest.mock('../MetadataCache', (): Record => ({ +// __esModule: true, +// ...jest.requireActual('../MetadataCache') +// })); + +// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-call +configure({ adapter: new Adapter() }); + +interface AccountTestJson extends AccountJson { + expectedIconTheme: IconTheme +} + +interface AccountTestGenesisJson extends AccountTestJson { + expectedEncodedAddress: string; + expectedNetworkLabel: string; + genesisHash: HexString; +} + +const externalAccount = { address: '5EeaoDj4VDk8V6yQngKBaCD5MpJUCHrhYjVhBjgMHXoYon1s', expectedIconTheme: 'polkadot', isExternal: true, name: 'External Account', type: 'sr25519' } as AccountJson; +const hardwareAccount = { + address: 'HDE6uFdw53SwUyfKSsjwZNmS2sziWMPuY6uJhGHcFzLYRaJ', + expectedIconTheme: 'polkadot', + // Kusama genesis hash + genesisHash: '0xb0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe', + isExternal: true, + isHardware: true, + name: 'Hardware Account', + type: 'sr25519' +} as AccountJson; + +const accounts = [ + { address: '5HSDXAC3qEMkSzZK377sTD1zJhjaPiX5tNWppHx2RQMYkjaJ', expectedIconTheme: 'polkadot', name: 'ECDSA Account', type: 'ecdsa' }, + { address: '5FjgD3Ns2UpnHJPVeRViMhCttuemaRXEqaD8V5z4vxcsUByA', expectedIconTheme: 'polkadot', name: 'Ed Account', type: 'ed25519' }, + { address: '5Ggap6soAPaP5UeNaiJsgqQwdVhhNnm6ez7Ba1w9jJ62LM2Q', expectedIconTheme: 'polkadot', name: 'Parent Sr Account', type: 'sr25519' }, + { address: '0xd5D81CD4236a43F48A983fc5B895975c511f634D', expectedIconTheme: 'ethereum', name: 'Ethereum', type: 'ethereum' }, + { ...externalAccount }, + { ...hardwareAccount } +] as AccountTestJson[]; + +// With Westend genesis Hash +// This account isn't part of the generic test because Westend isn't a built in network +// The network would only be displayed if the corresponding metadata are known +const westEndAccount = { + address: 'Cs2LLqQ6DSRx8UPdVp6jny4DvwNqziBSowSu5Nb1u3R6Z7X', + expectedEncodedAddress: '5CMQg2VXTrRWCUewro13qqc45Lf93KtzzS6hWR6dY6pvMZNF', + expectedIconTheme: 'polkadot', + expectedNetworkLabel: 'Westend', + genesisHash: '0xe143f23803ac50e8f6f8e62695d1ce9e4e1d68aa36c1cd2cfd15340213f3423e', + name: 'acc', + type: 'ed25519' +} as AccountTestGenesisJson; + +const accountsWithGenesisHash = [ + // with Polkadot genesis Hash + { + address: '5Ggap6soAPaP5UeNaiJsgqQwdVhhNnm6ez7Ba1w9jJ62LM2Q', + expectedEncodedAddress: '15csxS8s2AqrX1etYMMspzF6V7hM56KEjUqfjJvWHP7YWkoF', + expectedIconTheme: 'polkadot', + expectedNetworkLabel: 'Polkadot', + genesisHash: '0x91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3', + type: 'sr25519' + }, + // with Kusama genesis Hash + { + address: '5DoYawpxt6aBy1pKAt1beLMrakqtbWMtG3NF6jwRR8uKJGqD', + expectedEncodedAddress: 'EKAFGAqWTb7ifdkwapeYHirjM88QBB4iRCzVQDNtw7p3bgF', + expectedIconTheme: 'polkadot', + expectedNetworkLabel: 'Kusama', + genesisHash: '0xb0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe', + type: 'sr25519' + }, + // with Edgeware genesis Hash + { + address: '5GYQRJj3NUznYDzCduENRcocMsyxmb6tjb5xW87ZMErBe9R7', + expectedEncodedAddress: 'mzKNamvvJPM5ApxwGSYD5VjjtyfrB4g8fhMyCc29K37nuop', + expectedIconTheme: 'substrate', + expectedNetworkLabel: 'Edgeware', + genesisHash: '0x742a2ca70c2fda6cee4f8df98d64c4c670a052d9568058982dad9d5a7a135c5b', + type: 'sr25519' + } +] as AccountTestGenesisJson[]; + +const mountComponent = async (addressComponentProps: AddressComponentProps, contextAccounts: AccountJson[]): Promise<{ + wrapper: ReactWrapper; +}> => { + const actionStub = jest.fn(); + const { actions = actionStub } = addressComponentProps; + + const wrapper = mount( + +
    + + ); + + await act(flushAllPromises); + wrapper.update(); + + return { wrapper }; +}; + +const getWrapper = async (account: AccountJson, contextAccounts: AccountJson[], withAccountsInContext: boolean) => { + // the address component can query info about the account from the account context + // in this case, the account's address (any encoding) should suffice + // In case the account is not in the context, then more info are needed as props + // to display accurately + const mountedComponent = withAccountsInContext + // only the address is passed as props, the full acount info are loaded in the context + ? await mountComponent({ address: account.address }, contextAccounts) + // the context is empty, all account's info are passed as props to the Address component + : await mountComponent(account, []); + + return mountedComponent.wrapper; +}; + +const genericTestSuite = (account: AccountTestJson, withAccountsInContext = true) => { + let wrapper: ReactWrapper; + const { address, expectedIconTheme, name = '', type = DEFAULT_TYPE } = account; + + describe(`Account ${withAccountsInContext ? 'in context from address' : 'from props'} (${name}) - ${type}`, () => { + beforeAll(async () => { + wrapper = await getWrapper(account, accounts, withAccountsInContext); + }); + + it('shows the account address and name', () => { + expect(wrapper.find('[data-field="address"]').text()).toEqual(address); + expect(wrapper.find('Name span').text()).toEqual(name); + }); + + it(`shows a ${expectedIconTheme} identicon`, () => { + expect(wrapper.find('Identicon').first().prop('iconTheme')).toEqual(expectedIconTheme); + }); + + it('can copy its address', () => { + // the first CopyToClipboard is from the identicon, the second from the copy button + expect(wrapper.find('CopyToClipboard').at(0).prop('text')).toEqual(address); + expect(wrapper.find('CopyToClipboard').at(1).prop('text')).toEqual(address); + }); + + it('has the account visiblity icon', () => { + expect(wrapper.find('FontAwesomeIcon.visibleIcon')).toHaveLength(1); + }); + + it('can hide the account', () => { + jest.spyOn(messaging, 'showAccount').mockImplementation(() => Promise.resolve(false)); + + const visibleIcon = wrapper.find('FontAwesomeIcon.visibleIcon'); + const hiddenIcon = wrapper.find('FontAwesomeIcon.hiddenIcon'); + + expect(visibleIcon.exists()).toBe(true); + expect(hiddenIcon.exists()).toBe(false); + + visibleIcon.simulate('click'); + expect(messaging.showAccount).toHaveBeenCalledWith(address, false); + }); + + it('can show the account if hidden', async () => { + const additionalProps = { isHidden: true }; + + const mountedHiddenComponent = withAccountsInContext + ? await mountComponent({ address, ...additionalProps }, accounts) + : await mountComponent({ ...account, ...additionalProps }, []); + + const wrapperHidden = mountedHiddenComponent.wrapper; + + jest.spyOn(messaging, 'showAccount').mockImplementation(() => Promise.resolve(true)); + + const visibleIcon = wrapperHidden.find('FontAwesomeIcon.visibleIcon'); + const hiddenIcon = wrapperHidden.find('FontAwesomeIcon.hiddenIcon'); + + expect(visibleIcon.exists()).toBe(false); + expect(hiddenIcon.exists()).toBe(true); + + hiddenIcon.simulate('click'); + expect(messaging.showAccount).toHaveBeenCalledWith(address, true); + }); + + it('has settings button', () => { + expect(wrapper.find('.settings')).toHaveLength(1); + }); + + it('has no account hidding and settings button if no action is provided', async () => { + const additionalProps = { actions: null }; + + const mountedComponentWithoutAction = withAccountsInContext + ? await mountComponent({ address, ...additionalProps }, accounts) + : await mountComponent({ ...account, ...additionalProps }, []); + + wrapper = mountedComponentWithoutAction.wrapper; + + expect(wrapper.find('.settings')).toHaveLength(0); + }); + }); +}; + +const genesisHashTestSuite = (account: AccountTestGenesisJson, withAccountsInContext = true) => { + const { expectedEncodedAddress, expectedIconTheme, expectedNetworkLabel } = account; + + describe(`Account ${withAccountsInContext ? 'in context from address' : 'from props'} with ${expectedNetworkLabel} genesiHash`, () => { + let wrapper: ReactWrapper; + + beforeAll(async () => { + wrapper = await getWrapper(account, accountsWithGenesisHash, withAccountsInContext); + }); + + it('shows the account address correctly encoded', () => { + expect(wrapper.find('[data-field="address"]').text()).toEqual(expectedEncodedAddress); + }); + + it(`shows a ${expectedIconTheme} identicon`, () => { + expect(wrapper.find('Identicon').first().prop('iconTheme')).toEqual(expectedIconTheme); + }); + + it('Copy buttons contain the encoded address', () => { + // the first CopyToClipboard is from the identicon, the second from the copy button + expect(wrapper.find('CopyToClipboard').at(0).prop('text')).toEqual(expectedEncodedAddress); + expect(wrapper.find('CopyToClipboard').at(1).prop('text')).toEqual(expectedEncodedAddress); + }); + + it('Network label shows the correct network', () => { + expect(wrapper.find('[data-field="chain"]').text()).toEqual(expectedNetworkLabel); + }); + }); +}; + +describe('Address', () => { + accounts.forEach((account) => { + genericTestSuite(account); + genericTestSuite(account, false); + }); + + accountsWithGenesisHash.forEach((account) => { + genesisHashTestSuite(account); + genesisHashTestSuite(account, false); + }); + + describe('External account', () => { + let wrapper: ReactWrapper; + + beforeAll(async () => { + wrapper = await getWrapper(externalAccount, [], false); + }); + + it('has an icon in front of its name', () => { + expect(wrapper.find('Name').find('FontAwesomeIcon [data-icon="qrcode"]').exists()).toBe(true); + }); + }); + + describe('Hardware wallet account', () => { + let wrapper: ReactWrapper; + + beforeAll(async () => { + wrapper = await getWrapper(hardwareAccount, [], false); + }); + + it('has a usb icon in front of its name', () => { + expect(wrapper.find('Name').find('FontAwesomeIcon [data-icon="usb"]').exists()).toBe(true); + }); + }); + + describe('Encoding and label based on Metadata', () => { + let wrapper: ReactWrapper; + + beforeAll(async () => { + jest.spyOn(MetadataCache, 'getSavedMeta').mockImplementation(() => Promise.resolve(westendMetadata)); + + wrapper = await getWrapper(westEndAccount, [], false); + }); + + it('shows westend label with the correct color', () => { + const bannerChain = wrapper.find('[data-field="chain"]'); + + expect(bannerChain.text()).toEqual(westendMetadata.chain); + expect(bannerChain.prop('style')?.backgroundColor).toEqual(westendMetadata.color); + }); + + it('shows the account correctly reencoded', () => { + expect(wrapper.find('[data-field="address"]').text()).toEqual(westEndAccount.expectedEncodedAddress); + }); + }); + + describe('Derived accounts', () => { + let wrapper: ReactWrapper; + const childAccount = { + address: '5Ggap6soAPaP5UeNaiJsgqQwdVhhNnm6ez7Ba1w9jJ62LM2Q', + name: 'Luke', + parentName: 'Dark Vador', + suri: '//42', + type: 'sr25519' + } as AccountJson; + + beforeAll(async () => { + wrapper = await getWrapper(childAccount, [], false); + }); + + it('shows the child\'s account address and name', () => { + expect(wrapper.find('[data-field="address"]').text()).toEqual(childAccount.address); + expect(wrapper.find('Name span').text()).toEqual(childAccount.name); + }); + + it('shows the parent account and suri', () => { + const expectedParentNameSuri = getParentNameSuri(childAccount.parentName, childAccount.suri); + + expect(wrapper.find('.parentName').text()).toEqual(expectedParentNameSuri); + }); + }); +}); diff --git a/packages/extension-ui/src/components/Address.tsx b/packages/extension-ui/src/components/Address.tsx new file mode 100644 index 0000000..f02330c --- /dev/null +++ b/packages/extension-ui/src/components/Address.tsx @@ -0,0 +1,481 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { AccountJson, AccountWithChildren } from '@pezkuwi/extension-base/background/types'; +import type { Chain } from '@pezkuwi/extension-chains/types'; +import type { IconTheme } from '@pezkuwi/react-identicon/types'; +import type { SettingsStruct } from '@pezkuwi/ui-settings/types'; +import type { KeypairType } from '@pezkuwi/util-crypto/types'; + +import { faUsb } from '@fortawesome/free-brands-svg-icons'; +import { faCopy, faEye, faEyeSlash } from '@fortawesome/free-regular-svg-icons'; +import { faCodeBranch, faQrcode } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import React, { useCallback, useContext, useEffect, useRef, useState } from 'react'; +import CopyToClipboard from 'react-copy-to-clipboard'; + +import { hexToU8a, isHex } from '@pezkuwi/util'; +import { decodeAddress, encodeAddress } from '@pezkuwi/util-crypto'; + +import details from '../assets/details.svg'; +import { useMetadata, useOutsideClick, useToast, useTranslation } from '../hooks/index.js'; +import { showAccount } from '../messaging.js'; +import { styled } from '../styled.js'; +import { DEFAULT_TYPE } from '../util/defaultType.js'; +import getParentNameSuri from '../util/getParentNameSuri.js'; +import { AccountContext, SettingsContext } from './contexts.js'; +import Identicon from './Identicon.js'; +import Menu from './Menu.js'; +import Svg from './Svg.js'; + +export interface Props { + actions?: React.ReactNode; + address?: string | null; + children?: React.ReactNode; + className?: string; + genesisHash?: string | null; + isExternal?: boolean | null; + isHardware?: boolean | null; + isHidden?: boolean; + name?: string | null; + parentName?: string | null; + showVisibilityAction?: boolean + suri?: string; + toggleActions?: number; + type?: KeypairType; +} + +interface Recoded { + account: AccountJson | null; + formatted: string | null; + genesisHash?: string | null; + prefix?: number; + type: KeypairType; +} + +// find an account in our list +function findSubstrateAccount (accounts: AccountJson[], publicKey: Uint8Array): AccountJson | null { + const pkStr = publicKey.toString(); + + return accounts.find(({ address }): boolean => + decodeAddress(address).toString() === pkStr + ) || null; +} + +// find an account in our list +function findAccountByAddress (accounts: AccountJson[], _address: string): AccountJson | null { + return accounts.find(({ address }): boolean => + address === _address + ) || null; +} + +// recodes an supplied address using the prefix/genesisHash, include the actual saved account & chain +function recodeAddress (address: string, accounts: AccountWithChildren[], chain: Chain | null, settings: SettingsStruct): Recoded { + // decode and create a shortcut for the encoded address + const publicKey = isHex(address) ? hexToU8a(address) : decodeAddress(address); + // find our account using the actual publicKey, and then find the associated chain + const account = findSubstrateAccount(accounts, publicKey); + const prefix = chain ? chain.ss58Format : (settings.prefix === -1 ? 42 : settings.prefix); + + // always allow the actual settings to override the display + return { + account, + formatted: account?.type === 'ethereum' + ? address + : encodeAddress(publicKey, prefix), + genesisHash: account?.genesisHash, + prefix, + type: account?.type || DEFAULT_TYPE + }; +} + +const ACCOUNTS_SCREEN_HEIGHT = 550; +const defaultRecoded = { account: null, formatted: null, prefix: 42, type: DEFAULT_TYPE }; + +function Address ({ actions, address, children, className, genesisHash, isExternal, isHardware, isHidden, name, parentName, showVisibilityAction = false, suri, toggleActions, type: givenType }: Props): React.ReactElement { + const { t } = useTranslation(); + const { accounts } = useContext(AccountContext); + const settings = useContext(SettingsContext); + const [{ account, formatted, genesisHash: recodedGenesis, prefix, type }, setRecoded] = useState(defaultRecoded); + const chain = useMetadata(genesisHash || recodedGenesis, true); + + const [showActionsMenu, setShowActionsMenu] = useState(false); + const [moveMenuUp, setIsMovedMenu] = useState(false); + const actIconRef = useRef(null); + const actMenuRef = useRef(null); + const { show } = useToast(); + + useOutsideClick([actIconRef, actMenuRef], () => (showActionsMenu && setShowActionsMenu(!showActionsMenu))); + + useEffect((): void => { + if (!address) { + return setRecoded(defaultRecoded); + } + + const account = findAccountByAddress(accounts, address); + + setRecoded( + ( + chain?.definition.chainType === 'ethereum' || + account?.type === 'ethereum' || + (!account && givenType === 'ethereum') + ) + ? { account, formatted: address, type: 'ethereum' } + : recodeAddress(address, accounts, chain, settings) + ); + }, [accounts, address, chain, givenType, settings]); + + useEffect(() => { + if (!showActionsMenu) { + setIsMovedMenu(false); + } else if (actMenuRef.current) { + const { bottom } = actMenuRef.current.getBoundingClientRect(); + + if (bottom > ACCOUNTS_SCREEN_HEIGHT) { + setIsMovedMenu(true); + } + } + }, [showActionsMenu]); + + useEffect((): void => { + setShowActionsMenu(false); + }, [toggleActions]); + + const theme = ( + type === 'ethereum' + ? 'ethereum' + : (chain?.icon || 'polkadot') + ) as IconTheme; + + const _onClick = useCallback( + () => setShowActionsMenu(!showActionsMenu), + [showActionsMenu] + ); + + const _onCopy = useCallback( + () => show(t('Copied')), + [show, t] + ); + + const _toggleVisibility = useCallback( + (): void => { + address && showAccount(address, isHidden || false).catch(console.error); + }, + [address, isHidden] + ); + + const Name = () => { + const accountName = name || account?.name; + const displayName = accountName || t(''); + + return ( + <> + {!!accountName && (account?.isExternal || isExternal) && ( + (account?.isHardware || isHardware) + ? ( + + ) + : ( + + ) + )} + {displayName} + ); + }; + + const parentNameSuri = getParentNameSuri(parentName, suri); + + return ( +
    +
    + +
    + {parentName + ? ( + <> +
    + +
    + {parentNameSuri} +
    +
    +
    + +
    + + ) + : ( +
    + +
    + ) + } + {chain?.genesisHash && chain?.name && ( +
    + {chain.name.replace(' Relay Chain', '')} +
    + )} +
    +
    + {formatted || address || t('')} +
    + + + + {(actions || showVisibilityAction) && ( + + )} +
    +
    + {actions && ( + <> +
    + +
    + {showActionsMenu && ( + + {actions} + + )} + + )} +
    + {children} +
    + ); +} + +export default styled(Address)` + background: var(--boxBackground); + border: 1px solid var(--boxBorderColor); + box-sizing: border-box; + border-radius: 4px; + margin-bottom: 8px; + position: relative; + + .banner { + font-size: 12px; + line-height: 16px; + position: absolute; + top: 0; + + &.chain { + background: var(--primaryColor); + border-radius: 0 0 0 10px; + color: white; + padding: 0.1rem 0.5rem 0.1rem 0.75rem; + right: 0; + z-index: 1; + } + } + + .addressDisplay { + display: flex; + justify-content: space-between; + position: relative; + + .svg-inline--fa { + width: 14px; + height: 14px; + margin-right: 10px; + color: var(--accountDotsIconColor); + &:hover { + color: var(--labelColor); + cursor: pointer; + } + } + + .hiddenIcon, .visibleIcon { + position: absolute; + right: 2px; + top: -18px; + } + + .hiddenIcon { + color: var(--errorColor); + &:hover { + color: var(--accountDotsIconColor); + } + } + } + + .externalIcon, .hardwareIcon { + margin-right: 0.3rem; + color: var(--labelColor); + width: 0.875em; + } + + .identityIcon { + margin-left: 15px; + margin-right: 10px; + + & svg { + width: 50px; + height: 50px; + } + } + + .info { + width: 100%; + } + + .infoRow { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + height: 72px; + border-radius: 4px; + } + + img { + max-width: 50px; + max-height: 50px; + border-radius: 50%; + } + + .name { + font-size: 16px; + line-height: 22px; + margin: 2px 0; + overflow: hidden; + text-overflow: ellipsis; + width: 300px; + white-space: nowrap; + + &.displaced { + padding-top: 10px; + } + } + + .parentName { + color: var(--labelColor); + font-size: var(--inputLabelFontSize); + line-height: 14px; + overflow: hidden; + padding: 0.25rem 0 0 0.8rem; + text-overflow: ellipsis; + width: 270px; + white-space: nowrap; + } + + .fullAddress { + overflow: hidden; + text-overflow: ellipsis; + color: var(--labelColor); + font-size: 12px; + line-height: 16px; + } + + .detailsIcon { + background: var(--accountDotsIconColor); + width: 3px; + height: 19px; + + &.active { + background: var(--primaryColor); + } + } + + .deriveIcon { + color: var(--labelColor); + position: absolute; + top: 5px; + width: 9px; + height: 9px; + } + + .movableMenu { + margin-top: -20px; + right: 28px; + top: 0; + + &.isMoved { + top: auto; + bottom: 0; + } + } + + .settings { + position: relative; + display: flex; + justify-content: center; + align-items: center; + height: 100%; + width: 40px; + + &:before { + content: ''; + position: absolute; + left: 0; + top: 25%; + bottom: 25%; + width: 1px; + background: var(--boxBorderColor); + } + + &:hover { + cursor: pointer; + background: var(--readonlyInputBackground); + } + } +`; diff --git a/packages/extension-ui/src/components/BackButton.tsx b/packages/extension-ui/src/components/BackButton.tsx new file mode 100644 index 0000000..c422b16 --- /dev/null +++ b/packages/extension-ui/src/components/BackButton.tsx @@ -0,0 +1,45 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import { faArrowLeft } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import React from 'react'; + +import { styled } from '../styled.js'; +import Button from './Button.js'; + +interface Props { + className?: string; + onClick: () => void; +} + +function BackButton ({ className, onClick }: Props): React.ReactElement { + return ( + + ); +} + +export default styled(BackButton)` + background: var(--backButtonBackground); + margin-right: 11px; + width: 42px; + + .arrowLeft { + color: var(--backButtonTextColor); + display: block; + margin: auto; + } + + &:not(:disabled):hover { + background: var(--backButtonBackgroundHover); + } +`; diff --git a/packages/extension-ui/src/components/Box.tsx b/packages/extension-ui/src/components/Box.tsx new file mode 100644 index 0000000..c2cc816 --- /dev/null +++ b/packages/extension-ui/src/components/Box.tsx @@ -0,0 +1,44 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import React from 'react'; + +import { styled } from '../styled.js'; + +interface Props { + banner?: React.ReactNode; + children: React.ReactNode; + className?: string; +} + +function Box ({ banner, children, className }: Props): React.ReactElement { + return ( +
    + {children} + {banner &&
    {banner}
    } +
    + ); +} + +export default styled(Box)` + background: var(--readonlyInputBackground); + border: 1px solid var(--inputBorderColor); + border-radius: var(--borderRadius); + color: var(--subTextColor); + font-family: var(--fontFamily); + font-size: var(--fontSize); + margin: 0.75rem 24px; + padding: var(--boxPadding); + position: relative; + + .banner { + background: darkorange; + border-radius: 0 var(--borderRadius) 0 var(--borderRadius); + color: white; + font-size: 0.75rem; + padding: 0.25rem 0.5rem; + position: absolute; + right: 0; + top: 0; + } +`; diff --git a/packages/extension-ui/src/components/BoxWithLabel.tsx b/packages/extension-ui/src/components/BoxWithLabel.tsx new file mode 100644 index 0000000..b140847 --- /dev/null +++ b/packages/extension-ui/src/components/BoxWithLabel.tsx @@ -0,0 +1,42 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import React from 'react'; +import styled from 'styled-components'; + +import Label from './Label.js'; + +interface Props { + className?: string; + label: string; + value?: string; +} + +function BoxWithLabel ({ className, label, value }: Props): React.ReactElement { + return ( + + ); +} + +export default styled(BoxWithLabel)` + .seedBox { + background: var(--readonlyInputBackground); + box-shadow: none; + border-radius: var(--borderRadius); + border: 1px solid var(--inputBorderColor); + border-color: var(--inputBorderColor); + box-sizing: border-box; + display: block; + font-family: var(--fontFamily); + outline: none; + resize: none; + width: 100%; + } +`; diff --git a/packages/extension-ui/src/components/Button.tsx b/packages/extension-ui/src/components/Button.tsx new file mode 100644 index 0000000..ecf43c8 --- /dev/null +++ b/packages/extension-ui/src/components/Button.tsx @@ -0,0 +1,106 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import React, { useCallback } from 'react'; + +import { styled } from '../styled.js'; +import Spinner from './Spinner.js'; + +export interface ButtonProps { + className?: string; + children?: React.ReactNode; + isBusy?: boolean; + isDanger?: boolean; + isDisabled?: boolean; + onClick?: () => void | Promise | null; + to?: string; +} + +function Button ({ children, className = '', isBusy, isDisabled, onClick, to }: ButtonProps): React.ReactElement { + const _onClick = useCallback( + (): void => { + if (isBusy || isDisabled) { + return; + } + + onClick && Promise.resolve(onClick()).catch(console.error); + + if (to) { + window.location.hash = to; + } + }, + [isBusy, isDisabled, onClick, to] + ); + + return ( + + ); +} + +export default styled(Button)(({ isDanger }) => ` + background: var(${isDanger ? '--buttonBackgroundDanger' : '--buttonBackground'}); + cursor: pointer; + display: block; + width: 100%; + height: ${isDanger ? '40px' : '48px'}; + box-sizing: border-box; + border: none; + border-radius: var(--borderRadius); + color: var(--buttonTextColor); + font-size: 15px; + line-height: 20px; + padding: 0 1rem; + position: relative; + text-align: center; + + &:disabled { + cursor: default; + } + + &:not(:disabled):hover { + background: var(${isDanger ? '--buttonBackgroundDangerHover' : '--buttonBackgroundHover'}); + } + + .busyOverlay, + .disabledOverlay { + visibility: hidden; + } + + .disabledOverlay { + background: rgba(96,96,96,0.75); + border-radius: var(--borderRadius); + bottom: 0; + left: 0; + position: absolute; + right: 0; + top: 0; + } + + svg { + margin-right: 0.3rem; + } + + &.isBusy { + background: rgba(96,96,96,0.15); + + .children { + opacity: 0.25; + } + + .busyOverlay { + visibility: visible; + } + } + + &.isDisabled .disabledOverlay { + visibility: visible; + } +`); diff --git a/packages/extension-ui/src/components/ButtonArea.tsx b/packages/extension-ui/src/components/ButtonArea.tsx new file mode 100644 index 0000000..23881ba --- /dev/null +++ b/packages/extension-ui/src/components/ButtonArea.tsx @@ -0,0 +1,33 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import React from 'react'; + +import { styled } from '../styled.js'; + +interface Props { + className?: string; + children: React.ReactNode; +} + +function ButtonArea ({ children, className }: Props): React.ReactElement { + return ( +
    + {children} +
    + ); +} + +export default styled(ButtonArea)` + display: flex; + flex-direction: row; + background: var(--highlightedAreaBackground); + border-top: 1px solid var(--inputBorderColor); + padding: 12px 24px; + margin-left: 0; + margin-right: 0; + + & > button:not(:last-of-type) { + margin-right: 8px; + } +`; diff --git a/packages/extension-ui/src/components/ButtonWithSubtitle.tsx b/packages/extension-ui/src/components/ButtonWithSubtitle.tsx new file mode 100644 index 0000000..6cbdacb --- /dev/null +++ b/packages/extension-ui/src/components/ButtonWithSubtitle.tsx @@ -0,0 +1,43 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import React from 'react'; + +import { styled } from '../styled.js'; +import { Button } from './index.js'; + +interface ButtonWithSubtitleProps { + title: string; + subTitle: string; + children?: string; + to: string; +} + +export default function ButtonWithSubtitle ({ children, subTitle, title, to }: ButtonWithSubtitleProps): React.ReactElement { + return ( + +

    {title}

    + {subTitle} + {children} +
    + ); +} + +const StyledButton = styled(Button)` + button { + padding-top: 0; + padding-bottom: 0; + } + + p { + margin: 0; + font-size: 15px; + line-height: 20px; + } + + span { + display: block; + font-size: 12px; + line-height: 16px; + } +`; diff --git a/packages/extension-ui/src/components/Checkbox.tsx b/packages/extension-ui/src/components/Checkbox.tsx new file mode 100644 index 0000000..7d33a90 --- /dev/null +++ b/packages/extension-ui/src/components/Checkbox.tsx @@ -0,0 +1,113 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import React, { useCallback, useEffect } from 'react'; + +import Checkmark from '../assets/checkmark.svg'; +import { styled } from '../styled.js'; + +interface Props { + checked: boolean; + indeterminate?: boolean; + className?: string; + label: string; + onChange?: (checked: boolean) => void; + onClick?: () => void; +} + +function Checkbox ({ checked, className, indeterminate, label, onChange, onClick }: Props): React.ReactElement { + const checkboxRef = React.useRef(null); + + useEffect(() => { + if (checkboxRef.current) { + checkboxRef.current.indeterminate = !!indeterminate; + } + }, [indeterminate]); + + const _onChange = useCallback( + (event: React.ChangeEvent) => onChange && onChange(event.target.checked), + [onChange] + ); + + const _onClick = useCallback( + () => onClick && onClick(), + [onClick] + ); + + return ( +
    + +
    + ); +} + +export default styled(Checkbox)` + margin: var(--boxMargin); + + label { + display: block; + position: relative; + cursor: pointer; + user-select: none; + padding-left: 24px; + padding-top: 1px; + color: var(--subTextColor); + font-size: var(--fontSize); + line-height: var(--lineHeight); + + & input { + position: absolute; + opacity: 0; + cursor: pointer; + height: 0; + width: 0; + } + + & span { + position: absolute; + top: 4px; + left: 0; + height: 16px; + width: 16px; + border-radius: var(--borderRadius); + background-color: var(--readonlyInputBackground); + border: 1px solid var(--inputBorderColor); + border: 1px solid var(--inputBorderColor); + + &:after { + content: ''; + display: none; + width: 13px; + height: 10px; + position: absolute; + left: 1px; + top: 2px; + mask: url(${Checkmark}); + mask-size: cover; + background: var(--primaryColor); + } + } + + &:hover input ~ span { + background-color: var(--inputBackground); + } + + input:checked ~ span:after { + display: block; + } + + input:indeterminate ~ span { + background: var(--primaryColor) + } + } +`; diff --git a/packages/extension-ui/src/components/Dropdown.tsx b/packages/extension-ui/src/components/Dropdown.tsx new file mode 100644 index 0000000..3aa2f86 --- /dev/null +++ b/packages/extension-ui/src/components/Dropdown.tsx @@ -0,0 +1,102 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import React, { useCallback } from 'react'; + +import arrow from '../assets/arrow-down.svg'; +import { styled } from '../styled.js'; +import Label from './Label.js'; + +interface DropdownOption { + text: string | React.ReactNode; + value?: string | number | null; +} + +interface Props { + className?: string; + defaultValue?: string | null; + isDisabled?: boolean + isError?: boolean; + isFocussed?: boolean; + label: string; + onBlur?: () => void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onChange?: (value: any) => void; + options: DropdownOption[]; + value?: number | string | null; +} + +function Dropdown ({ className, defaultValue, isDisabled, isFocussed, label, onBlur, onChange, options, value }: Props): React.ReactElement { + const _onChange = useCallback( + ({ target: { value } }: React.ChangeEvent) => + onChange && onChange(value.trim()), + [onChange] + ); + + return ( + <> + + + ); +} + +export default React.memo(styled(Dropdown)(({ isError, label }) => ` + position: relative; + + select { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + background: var(--readonlyInputBackground); + border-color: var(${isError ? '--errorBorderColor' : '--inputBorderColor'}); + border-radius: var(--borderRadius); + border-style: solid; + border-width: 1px; + box-sizing: border-box; + color: var(${isError ? '--errorBorderColor' : '--textColor'}); + display: block; + font-family: var(--fontFamily); + font-size: var(--fontSize); + padding: 0.5rem 0.75rem; + width: 100%; + cursor: pointer; + + &:read-only { + box-shadow: none; + outline: none; + } + } + + label::after { + content: ''; + position: absolute; + top: ${label ? 'calc(50% + 14px)' : '50%'}; + transform: translateY(-50%); + right: 12px; + width: 8px; + height: 6px; + background: url(${arrow}) center no-repeat; + pointer-events: none; + } +`)); diff --git a/packages/extension-ui/src/components/ErrorBoundary.tsx b/packages/extension-ui/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..a23ccdc --- /dev/null +++ b/packages/extension-ui/src/components/ErrorBoundary.tsx @@ -0,0 +1,62 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributor +// SPDX-License-Identifier: Apache-2.0 + +import React, { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import Header from '../partials/Header'; +import Button from './Button'; +import ButtonArea from './ButtonArea'; +import VerticalSpace from './VerticalSpace'; + +interface ErrorBoundaryProps { + children: React.ReactNode; + className?: string; + error?: Error | null; + trigger?: string; +} + +const ErrorBoundary: React.FC = ({ children, error: propError, trigger }) => { + const { t } = useTranslation(); + const [error, setError] = useState(null); + + useEffect(() => { + if (error !== null && trigger) { + setError(null); + } + }, [error, trigger]); + + useEffect(() => { + if (propError) { + setError(propError); + } + }, [propError]); + + const goHome = useCallback(() => { + setError(null); + window.location.hash = '/'; + }, [setError]); + + if (error) { + return ( + <> +
    +
    + {t('Something went wrong with the query and rendering of this component. {{message}}', { + replace: { message: error.message } + })} +
    + + + + + + ); + } + + return <>{children}; +}; + +export default ErrorBoundary; diff --git a/packages/extension-ui/src/components/Icon.tsx b/packages/extension-ui/src/components/Icon.tsx new file mode 100644 index 0000000..050297e --- /dev/null +++ b/packages/extension-ui/src/components/Icon.tsx @@ -0,0 +1,31 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import React from 'react'; + +import { styled } from '../styled.js'; + +interface Props { + className?: string; + icon: string; + onClick?: () => void; +} + +function Icon ({ className = '', icon, onClick }: Props): React.ReactElement { + return ( +
    + {icon} +
    + ); +} + +export default styled(Icon)(({ onClick }) => ` + background: white; + border-radius: 50%; + box-sizing: border-box; + cursor: ${onClick ? 'pointer' : 'inherit'}; + text-align: center; +`); diff --git a/packages/extension-ui/src/components/Identicon.tsx b/packages/extension-ui/src/components/Identicon.tsx new file mode 100644 index 0000000..b005ad1 --- /dev/null +++ b/packages/extension-ui/src/components/Identicon.tsx @@ -0,0 +1,52 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { IconTheme } from '@pezkuwi/react-identicon/types'; + +import React from 'react'; + +import { Identicon as Icon } from '@pezkuwi/react-identicon'; + +import { styled } from '../styled.js'; + +interface Props { + className?: string; + iconTheme?: IconTheme; + isExternal?: boolean | null; + onCopy?: () => void; + prefix?: number; + value?: string | null; +} + +function Identicon ({ className, iconTheme, onCopy, prefix, value }: Props): React.ReactElement { + return ( +
    + +
    + ); +} + +export default styled(Identicon)` + background: rgba(192, 192, 292, 0.25); + border-radius: 50%; + display: flex; + justify-content: center; + + .container:before { + box-shadow: none; + background: var(--identiconBackground); + } + + svg { + circle:first-of-type { + display: none; + } + } +`; diff --git a/packages/extension-ui/src/components/InputFileWithLabel.tsx b/packages/extension-ui/src/components/InputFileWithLabel.tsx new file mode 100644 index 0000000..47327f8 --- /dev/null +++ b/packages/extension-ui/src/components/InputFileWithLabel.tsx @@ -0,0 +1,146 @@ +// Copyright 2017-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { DropzoneRef } from 'react-dropzone'; + +import React, { createRef, useCallback, useState } from 'react'; +import Dropzone from 'react-dropzone'; + +import { formatNumber, hexToU8a, isHex, u8aToString } from '@pezkuwi/util'; + +import { useTranslation } from '../hooks/index.js'; +import { styled } from '../styled.js'; +import Label from './Label.js'; + +function classes (...classNames: (boolean | null | string | undefined)[]): string { + return classNames + .filter((className): boolean => !!className) + .join(' '); +} + +export interface InputFileProps { + // Reference Example Usage: https://github.com/react-dropzone/react-dropzone/tree/master/examples/Accept + // i.e. MIME types: 'application/json, text/plain', or '.json, .txt' + className?: string; + accept?: string; + clearContent?: boolean; + convertHex?: boolean; + help?: React.ReactNode; + isDisabled?: boolean; + isError?: boolean; + label: string; + onChange?: (contents: Uint8Array, name: string) => void; + placeholder?: React.ReactNode | null; + withEllipsis?: boolean; + withLabel?: boolean; +} + +interface FileState { + name: string; + size: number; +} + +const BYTE_STR_0 = '0'.charCodeAt(0); +const BYTE_STR_X = 'x'.charCodeAt(0); +const NOOP = (): void => undefined; + +function convertResult (result: ArrayBuffer, convertHex?: boolean): Uint8Array { + const data = new Uint8Array(result); + + // this converts the input (if detected as hex), vai the hex conversion route + if (convertHex && data[0] === BYTE_STR_0 && data[1] === BYTE_STR_X) { + const hex = u8aToString(data); + + if (isHex(hex)) { + return hexToU8a(hex); + } + } + + return data; +} + +function InputFile ({ accept, className = '', clearContent, convertHex, isDisabled, isError = false, label, onChange, placeholder }: InputFileProps): React.ReactElement { + const { t } = useTranslation(); + const dropRef = createRef(); + const [file, setFile] = useState(); + + const _onDrop = useCallback( + (files: File[]): void => { + files.forEach((file): void => { + const reader = new FileReader(); + + reader.onabort = NOOP; + reader.onerror = NOOP; + + reader.onload = ({ target }: ProgressEvent): void => { + if (target?.result) { + const name = file.name; + const data = convertResult(target.result as ArrayBuffer, convertHex); + + onChange && onChange(data, name); + dropRef && setFile({ + name, + size: data.length + }); + } + }; + + reader.readAsArrayBuffer(file); + }); + }, + [convertHex, dropRef, onChange] + ); + + const dropZone = ( + + {({ getInputProps, getRootProps }): React.ReactElement => ( +
    + + + { + !file || clearContent + ? placeholder || t('click to select or drag and drop the file here') + : placeholder || t('{{name}} ({{size}} bytes)', { + replace: { + name: file.name, + size: formatNumber(file.size) + } + }) + } + +
    + )} +
    + ); + + return label + ? ( + + ) + : dropZone; +} + +export default React.memo(styled(InputFile)(({ isError }) => ` + border: 1px solid var(${isError ? '--errorBorderColor' : '--inputBorderColor'}); + background: var(--inputBackground); + border-radius: var(--borderRadius); + color: var(${isError ? '--errorBorderColor' : '--textColor'}); + font-size: 1rem; + margin: 0.25rem 0; + overflow-wrap: anywhere; + padding: 0.5rem 0.75rem; + + &:hover { + cursor: pointer; + } +`)); diff --git a/packages/extension-ui/src/components/InputFilter.tsx b/packages/extension-ui/src/components/InputFilter.tsx new file mode 100644 index 0000000..7a336dc --- /dev/null +++ b/packages/extension-ui/src/components/InputFilter.tsx @@ -0,0 +1,67 @@ +// Copyright 2017-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import { faTimes } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import React, { useCallback, useRef } from 'react'; + +import { styled } from '../styled.js'; +import { Input } from './TextInputs.js'; + +interface Props { + className?: string; + onChange: (filter: string) => void; + placeholder: string; + value: string; + withReset?: boolean; +} + +function InputFilter ({ className, onChange, placeholder, value, withReset = false }: Props) { + const inputRef: React.RefObject = useRef(null); + + const onChangeFilter = useCallback((event: React.ChangeEvent) => { + onChange(event.target.value); + }, [onChange]); + + const onResetFilter = useCallback(() => { + onChange(''); + inputRef?.current && inputRef.current.select(); + }, [onChange]); + + return ( +
    + + {withReset && !!value && ( + + )} +
    + ); +} + +export default styled(InputFilter)` + padding-left: 1rem !important; + padding-right: 1rem !important; + position: relative; + + .resetIcon { + position: absolute; + right: 28px; + top: 12px; + color: var(--iconNeutralColor); + cursor: pointer; + } +`; diff --git a/packages/extension-ui/src/components/InputWithLabel.tsx b/packages/extension-ui/src/components/InputWithLabel.tsx new file mode 100644 index 0000000..1653b88 --- /dev/null +++ b/packages/extension-ui/src/components/InputWithLabel.tsx @@ -0,0 +1,95 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import React, { useCallback, useState } from 'react'; + +import { useTranslation } from '../hooks/index.js'; +import { styled } from '../styled.js'; +import Label from './Label.js'; +import { Input } from './TextInputs.js'; +import Warning from './Warning.js'; + +interface Props { + className?: string; + defaultValue?: string | null; + disabled?: boolean; + isError?: boolean; + isFocused?: boolean; + isReadOnly?: boolean; + label: string; + onBlur?: () => void; + onFocus?: () => void; + onChange?: (value: string) => void; + onEnter?: () => void; + placeholder?: string; + type?: 'text' | 'password'; + value?: string; + withoutMargin?: boolean; +} + +function InputWithLabel ({ className, defaultValue, disabled, isError, isFocused, isReadOnly, label = '', onBlur, onChange, onEnter, onFocus, placeholder, type = 'text', value, withoutMargin }: Props): React.ReactElement { + const [isCapsLock, setIsCapsLock] = useState(false); + const { t } = useTranslation(); + + const _checkKey = useCallback( + (event: React.KeyboardEvent): void => { + onEnter && event.key === 'Enter' && onEnter(); + + if (type === 'password') { + if (event.getModifierState('CapsLock')) { + setIsCapsLock(true); + } else { + setIsCapsLock(false); + } + } + }, + [onEnter, type] + ); + + const _onChange = useCallback( + ({ target: { value } }: React.ChangeEvent): void => { + onChange && onChange(value); + }, + [onChange] + ); + + return ( + + ); +} + +export default styled(InputWithLabel)` + margin-bottom: 16px; + + &.withoutMargin { + margin-bottom: 0px; + + + .danger { + margin-top: 6px; + } + } +`; diff --git a/packages/extension-ui/src/components/Label.tsx b/packages/extension-ui/src/components/Label.tsx new file mode 100644 index 0000000..49a1db0 --- /dev/null +++ b/packages/extension-ui/src/components/Label.tsx @@ -0,0 +1,34 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import React from 'react'; + +import { styled } from '../styled.js'; + +interface Props { + children: React.ReactNode; + className?: string; + label: string; +} + +function Label ({ children, className, label }: Props): React.ReactElement { + return ( +
    + + {children} +
    + ); +} + +export default styled(Label)` + color: var(--textColor); + + label { + font-size: var(--inputLabelFontSize); + line-height: 14px; + letter-spacing: 0.04em; + opacity: 0.65; + margin-bottom: 12px; + text-transform: uppercase; + } +`; diff --git a/packages/extension-ui/src/components/Link.tsx b/packages/extension-ui/src/components/Link.tsx new file mode 100644 index 0000000..f3b428b --- /dev/null +++ b/packages/extension-ui/src/components/Link.tsx @@ -0,0 +1,74 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import React from 'react'; +import { Link as RouterLink } from 'react-router-dom'; + +import { styled } from '../styled.js'; + +interface Props { + children?: React.ReactNode; + className?: string; + isDanger?: boolean; + isDisabled?: boolean; + onClick?: () => void; + title?: string; + to?: string; +} + +function Link ({ children, className = '', isDisabled, onClick, title, to }: Props): React.ReactElement { + if (isDisabled) { + return ( +
    + {children} +
    + ); + } + + return to + ? ( + + {children} + + ) + : ( + + {children} + + ); +} + +export default styled(Link)(({ isDanger }) => ` + align-items: center; + color: var(${isDanger ? '--textColorDanger' : '--textColor'}); + display: flex; + opacity: 0.85; + text-decoration: none; + vertical-align: middle; + + &:hover { + color: var(${isDanger ? '--textColorDanger' : '--textColor'}); + opacity: 1.0; + } + + &:visited { + color: var(${isDanger ? '--textColorDanger' : '--textColor'}); + } + + &.isDisabled { + opacity: 0.4; + } +`); diff --git a/packages/extension-ui/src/components/List.tsx b/packages/extension-ui/src/components/List.tsx new file mode 100644 index 0000000..e1aa8ec --- /dev/null +++ b/packages/extension-ui/src/components/List.tsx @@ -0,0 +1,38 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import React from 'react'; + +import { styled } from '../styled.js'; + +interface Props { + className?: string; + children: React.ReactNode; +} + +const List = ({ children, className }: Props) => ( +
      + {children} +
    +); + +export default styled(List)` + list-style: none; + padding-inline-start: 10px; + padding-inline-end: 10px; + text-indent: -22px; + margin-left: 21px; + + li { + margin-bottom: 8px; + } + + li::before { + content: '\\2022'; + color: var(--primaryColor); + font-size: 30px; + font-weight: bold; + margin-right: 10px; + vertical-align: -20%; + } +`; diff --git a/packages/extension-ui/src/components/Loading.tsx b/packages/extension-ui/src/components/Loading.tsx new file mode 100644 index 0000000..883c837 --- /dev/null +++ b/packages/extension-ui/src/components/Loading.tsx @@ -0,0 +1,24 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import React from 'react'; + +import { useTranslation } from '../hooks/index.js'; + +interface Props { + children?: React.ReactNode; +} + +export default function Loading ({ children }: Props): React.ReactElement { + const { t } = useTranslation(); + + if (!children) { + return ( +
    {t('... loading ...')}
    + ); + } + + return ( + <>{children} + ); +} diff --git a/packages/extension-ui/src/components/Main.tsx b/packages/extension-ui/src/components/Main.tsx new file mode 100644 index 0000000..9e47d12 --- /dev/null +++ b/packages/extension-ui/src/components/Main.tsx @@ -0,0 +1,39 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import React from 'react'; + +import { styled } from '../styled.js'; + +interface Props { + children: React.ReactNode; + className?: string; +} + +function Main ({ children, className }: Props): React.ReactElement { + return ( +
    + {children} +
    + ); +} + +export default styled(Main)` + display: flex; + flex-direction: column; + height: calc(100vh - 2px); + background: var(--background); + color: var(--textColor); + font-size: var(--fontSize); + line-height: var(--lineHeight); + border: 1px solid var(--inputBorderColor); + + * { + font-family: var(--fontFamily); + } + + > * { + padding-left: 24px; + padding-right: 24px; + } +`; diff --git a/packages/extension-ui/src/components/Menu.tsx b/packages/extension-ui/src/components/Menu.tsx new file mode 100644 index 0000000..6011745 --- /dev/null +++ b/packages/extension-ui/src/components/Menu.tsx @@ -0,0 +1,37 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import React, { forwardRef } from 'react'; + +import { styled } from '../styled.js'; + +interface Props { + children: React.ReactNode; + className?: string; +} + +const Menu = forwardRef(({ children, className }, ref) => { + return ( +
    + {children} +
    + ); +}); + +Menu.displayName = 'Menu'; + +export default styled(Menu)` + background: var(--popupBackground); + border-radius: 4px; + border: 1px solid var(--boxBorderColor); + box-sizing: border-box; + box-shadow: 0 0 10px var(--boxShadow); + margin-top: 60px; + padding: 16px 0; + position: absolute; + right: 0; + z-index: 2; +`; diff --git a/packages/extension-ui/src/components/MenuDivider.tsx b/packages/extension-ui/src/components/MenuDivider.tsx new file mode 100644 index 0000000..e404e3f --- /dev/null +++ b/packages/extension-ui/src/components/MenuDivider.tsx @@ -0,0 +1,22 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import React from 'react'; + +import { styled } from '../styled.js'; + +interface Props { + className?: string; +} + +function MenuDivider ({ className }: Props): React.ReactElement { + return ( +
    + ); +} + +export default styled(MenuDivider)` + padding-top: 16px; + margin-bottom: 16px; + border-bottom: 1px solid var(--inputBorderColor); +`; diff --git a/packages/extension-ui/src/components/MenuItem.tsx b/packages/extension-ui/src/components/MenuItem.tsx new file mode 100644 index 0000000..8d79bb7 --- /dev/null +++ b/packages/extension-ui/src/components/MenuItem.tsx @@ -0,0 +1,45 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import React from 'react'; + +import { styled } from '../styled.js'; + +interface Props { + children: React.ReactNode; + className?: string; + noBorder?: boolean; + title?: React.ReactNode; +} + +function MenuItem ({ children, className = '', title }: Props): React.ReactElement { + return ( +
    + {title && ( +
    {title}
    + )} + {children} +
    + ); +} + +export default styled(MenuItem)` + min-width: 13rem; + padding: 0 16px; + max-width: 100%; + + > .itemTitle { + margin: 0; + width: 100%; + font-size: var(--inputLabelFontSize); + line-height: 14px; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--textColor); + opacity: 0.65; + } + + &+&.isTitled { + margin-top: 16px; + } +`; diff --git a/packages/extension-ui/src/components/MnemonicSeed.tsx b/packages/extension-ui/src/components/MnemonicSeed.tsx new file mode 100644 index 0000000..65fbe69 --- /dev/null +++ b/packages/extension-ui/src/components/MnemonicSeed.tsx @@ -0,0 +1,69 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { MouseEventHandler } from 'react'; + +import { faCopy } from '@fortawesome/free-regular-svg-icons'; +import React from 'react'; +import CopyToClipboard from 'react-copy-to-clipboard'; + +import { useTranslation } from '../hooks/index.js'; +import { styled } from '../styled.js'; +import ActionText from './ActionText.js'; +import BoxWithLabel from './BoxWithLabel.js'; + +interface Props { + seed: string; + onCopy: MouseEventHandler; + className?: string; +} + +function MnemonicSeed ({ className, onCopy, seed }: Props): React.ReactElement { + const { t } = useTranslation(); + + return ( +
    + +
    + + + +
    +
    + ); +} + +export default styled(MnemonicSeed)` + margin-bottom: 21px; + + .buttonsRow { + display: flex; + flex-direction: row; + + .copyBtn { + margin-right: 32px; + } + } + + .mnemonicDisplay { + .seedBox { + color: var(--primaryColor); + font-size: var(--fontSize); + height: unset; + letter-spacing: -0.01em; + line-height: var(--lineHeight); + margin-bottom: 10px; + padding: 14px; + } + } +`; diff --git a/packages/extension-ui/src/components/NextStepButton.tsx b/packages/extension-ui/src/components/NextStepButton.tsx new file mode 100644 index 0000000..59d9efd --- /dev/null +++ b/packages/extension-ui/src/components/NextStepButton.tsx @@ -0,0 +1,33 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { ButtonProps } from './Button.js'; + +import { faArrowRight } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import React from 'react'; + +import { styled } from '../styled.js'; +import Button from './Button.js'; + +function NextStepButton ({ children, ...props }: ButtonProps): React.ReactElement { + return ( + + ); +} + +export default styled(NextStepButton)` + .arrowRight{ + float: right; + margin-top: 4px; + margin-right: 1px; + color: var(--buttonTextColor); + } +`; diff --git a/packages/extension-ui/src/components/PasswordStrengthIndicator.tsx b/packages/extension-ui/src/components/PasswordStrengthIndicator.tsx new file mode 100644 index 0000000..4181b97 --- /dev/null +++ b/packages/extension-ui/src/components/PasswordStrengthIndicator.tsx @@ -0,0 +1,74 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { PasswordStrength } from '../util/passwordValidation.js'; + +import React from 'react'; + +import { styled } from '../styled.js'; + +interface Props { + className?: string; + passwordStrength: PasswordStrength; +} + +function PasswordStrengthIndicator ({ className, passwordStrength }: Props): React.ReactElement { + const { feedback, score } = passwordStrength; + + const strengthColors = [ + '#ff4444', // Very Weak - Red + '#ffa700', // Weak - Orange + '#ffeb3b', // Medium - Yellow + '#00C853', // Strong - Light Green + '#00695C' // Very Strong - Dark Green + ]; + + return ( +
    +
    + {[0, 1, 2, 3, 4].map((index) => ( +
    + ))} +
    + {feedback.suggestions.length > 0 && ( +
      + {feedback.suggestions.map((suggestion, index) => ( +
    • {suggestion}
    • + ))} +
    + )} +
    + ); +} + +export default styled(PasswordStrengthIndicator)` + .strength-meter { + margin-top: 0.5rem; + display: flex; + margin-bottom: 0.5rem; + } + + .strength-segment { + flex: 1; + height: 4px; + margin: 0 2px; + } + + .strength-label { + font-size: 0.85rem; + margin-bottom: 0.5rem; + } + + .suggestions { + margin: 0.5rem 0; + padding-left: 1.5rem; + font-size: 0.85rem; + color: var(--warning-color); + } +`; diff --git a/packages/extension-ui/src/components/RemoveAuth.tsx b/packages/extension-ui/src/components/RemoveAuth.tsx new file mode 100644 index 0000000..51a862d --- /dev/null +++ b/packages/extension-ui/src/components/RemoveAuth.tsx @@ -0,0 +1,34 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import { faTrash } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import React from 'react'; + +import { styled } from '../styled.js'; + +interface Props { + className?: string + onRemove: () => void +} + +function RemoveAuth ({ className, onRemove }: Props): React.ReactElement { + return ( + + ); +} + +export default styled(RemoveAuth)` + cursor: pointer; + color: var(--labelColor); + margin-right: 1rem; + + &.selected { + color: var(--primaryColor); + } +`; diff --git a/packages/extension-ui/src/components/Spinner.tsx b/packages/extension-ui/src/components/Spinner.tsx new file mode 100644 index 0000000..f038439 --- /dev/null +++ b/packages/extension-ui/src/components/Spinner.tsx @@ -0,0 +1,31 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import React from 'react'; + +import spinnerSrc from '../assets/spinner.png'; +import { styled } from '../styled.js'; + +interface Props { + className?: string; + size?: 'normal'; +} + +function Spinner ({ className = '', size = 'normal' }: Props): React.ReactElement { + return ( + + ); +} + +export default React.memo(styled(Spinner)` + bottom: 0rem; + height: 3rem; + left: 50%; + margin-left: -1.5rem; + position: absolute; + width: 3rem; + z-index: +`); diff --git a/packages/extension-ui/src/components/Svg.tsx b/packages/extension-ui/src/components/Svg.tsx new file mode 100644 index 0000000..dae199c --- /dev/null +++ b/packages/extension-ui/src/components/Svg.tsx @@ -0,0 +1,20 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import React from 'react'; + +import { styled } from '../styled.js'; + +interface Props { + className?: string; + src: string; +} + +const Svg = ({ className }: Props) => ; + +export default styled(Svg)(({ src }) => ` + background: var(--textColor); + display: inline-block; + mask: url(${src}); + mask-size: cover; +`); diff --git a/packages/extension-ui/src/components/Switch.tsx b/packages/extension-ui/src/components/Switch.tsx new file mode 100644 index 0000000..a2465e6 --- /dev/null +++ b/packages/extension-ui/src/components/Switch.tsx @@ -0,0 +1,82 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import React, { useCallback } from 'react'; + +import { styled } from '../styled.js'; + +interface Props { + checked: boolean; + onChange: (checked: boolean) => void; + uncheckedLabel: string; + checkedLabel: string; + className?: string; +} + +function Switch ({ checked, checkedLabel, className, onChange, uncheckedLabel }: Props): React.ReactElement { + const _onChange = useCallback( + (event: React.ChangeEvent) => onChange(event.target.checked), + [onChange] + ); + + return ( +
    + {uncheckedLabel} + + {checkedLabel} +
    + ); +} + +export default styled(Switch)` + label { + position: relative; + display: inline-block; + width: 48px; + height: 24px; + margin: 8px; + } + + .checkbox { + opacity: 0; + width: 0; + height: 0; + + &:checked + .slider:before { + transform: translateX(24px); + } + } + + .slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: var(--readonlyInputBackground); + transition: 0.2s; + border-radius: 100px; + border: 1px solid var(--inputBorderColor); + + &:before { + position: absolute; + content: ''; + height: 16px; + width: 16px; + left: 4px; + bottom: 3px; + background-color: var(--primaryColor); + transition: 0.4s; + border-radius: 50%; + } + } +`; diff --git a/packages/extension-ui/src/components/Table.tsx b/packages/extension-ui/src/components/Table.tsx new file mode 100644 index 0000000..895938e --- /dev/null +++ b/packages/extension-ui/src/components/Table.tsx @@ -0,0 +1,74 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import React from 'react'; + +import { styled } from '../styled.js'; + +interface Props { + children: React.ReactNode; + className?: string; + isFull?: boolean; +} + +function Table ({ children, className = '', isFull }: Props): React.ReactElement { + return ( + + + {children} + +
    + ); +} + +export default React.memo(styled(Table)` + border: 0; + display: block; + font-size: var(--labelFontSize); + line-height: var(--labelLineHeight); + margin-bottom: 1rem; + + &.isFull { + height: 100%; + overflow: auto; + } + + td.data { + max-width: 0; + overflow: hidden; + text-align: left; + text-overflow: ellipsis; + vertical-align: middle; + width: 100%; + + pre { + font-family: inherit; + font-size: 0.75rem; + margin: 0; + } + } + + td.label { + opacity: 0.5; + padding: 0 0.5rem; + text-align: right; + vertical-align: top; + white-space: nowrap; + } + + details { + cursor: pointer; + max-width: 24rem; + + summary { + text-overflow: ellipsis; + outline: 0; + overflow: hidden; + white-space: nowrap; + } + + &[open] summary { + white-space: normal; + } + } +`); diff --git a/packages/extension-ui/src/components/TextAreaWithLabel.tsx b/packages/extension-ui/src/components/TextAreaWithLabel.tsx new file mode 100644 index 0000000..cc3b44b --- /dev/null +++ b/packages/extension-ui/src/components/TextAreaWithLabel.tsx @@ -0,0 +1,47 @@ +// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import React, { useCallback } from 'react'; + +import Label from './Label.js'; +import { TextArea } from './TextInputs.js'; + +interface Props { + className?: string; + isError?: boolean; + isFocused?: boolean; + isReadOnly?: boolean; + rowsCount?: number; + label: string; + onChange?: (value: string) => void; + value?: string; +} + +export default function TextAreaWithLabel ({ className, isError, isFocused, isReadOnly, label, onChange, rowsCount, value }: Props): React.ReactElement { + const _onChange = useCallback( + ({ target: { value } }: React.ChangeEvent): void => { + onChange && onChange(value); + }, + [onChange] + ); + + return ( +