├── .env.template ├── .eslintrc ├── .github ├── issue-comments-validate.yml └── workflows │ ├── ci.yml │ └── comments-moderation.yml ├── .gitignore ├── .npmrc ├── .nvmrc ├── .prettierrc ├── .storybook ├── main.js ├── mocks │ └── webextension-polyfill.mock.ts └── preview.jsx ├── .tool-versions ├── LICENSE ├── MessageSigning.md ├── NOTICE ├── README.md ├── babel.config.js ├── jest.config.js ├── jest.setup.js ├── package-lock.json ├── package.json ├── secrets.testing.js ├── src ├── api │ ├── extension │ │ ├── index.js │ │ └── wallet.js │ ├── loader.js │ ├── messaging.js │ ├── migration-tool │ │ ├── cross-extension-messaging │ │ │ ├── nami-migration-client.extension.ts │ │ │ ├── nami │ │ │ │ ├── await-lace-pong-response.ts │ │ │ │ ├── create-nami-migration-listener.spec.ts │ │ │ │ ├── create-nami-migration-listener.ts │ │ │ │ ├── environment.ts │ │ │ │ └── set-migration-to-in-progress.ts │ │ │ └── shared │ │ │ │ ├── exceptions.ts │ │ │ │ ├── test-helpers.ts │ │ │ │ └── types.ts │ │ └── migrator │ │ │ ├── migration-data.data.ts │ │ │ ├── migration-state.data.ts │ │ │ ├── nami-storage-mapper.data.ts │ │ │ ├── nami-storage-mapper.spec.ts │ │ │ ├── nami-storage.data.ts │ │ │ └── nami-storage.fixture.ts │ ├── util.js │ └── webpage │ │ ├── eventRegistration.js │ │ └── index.js ├── assets │ └── img │ │ ├── Nami.ai │ │ ├── ada.png │ │ ├── bannerBlack.svg │ │ ├── bannerWhite.svg │ │ ├── icon-128.png │ │ ├── icon-34.png │ │ ├── iohk.svg │ │ ├── iohkWhite.svg │ │ ├── ledgerLogo.svg │ │ ├── logo.svg │ │ ├── logoWhite.svg │ │ └── trezorLogo.svg ├── config │ ├── config.js │ └── provider.js ├── features │ ├── analytics │ │ ├── config.ts │ │ ├── event-tracker.ts │ │ ├── events.ts │ │ ├── hooks.ts │ │ ├── posthog.ts │ │ ├── provider.tsx │ │ ├── services.ts │ │ ├── types.ts │ │ └── ui │ │ │ └── AnalyticsConsentModal.jsx │ ├── feature-flags │ │ └── provider.tsx │ ├── sentry.js │ ├── settings │ │ └── legal │ │ │ └── LegalSettings.tsx │ └── terms-and-privacy │ │ ├── config.ts │ │ ├── hooks.ts │ │ ├── index.ts │ │ ├── provider.tsx │ │ ├── services.ts │ │ └── ui │ │ └── TermsAndPrivacyModal.tsx ├── manifest.json ├── migrations │ ├── migration.js │ └── versions │ │ ├── 1.0.0.js │ │ ├── 1.1.5.js │ │ ├── 1.1.7.js │ │ ├── 2.0.0.js │ │ ├── 2.1.0.js │ │ ├── 2.2.0.js │ │ ├── 2.3.0.js │ │ ├── 2.3.2.js │ │ ├── 2.3.3.js │ │ ├── 3.0.0.js │ │ ├── 3.0.2.js │ │ └── 3.3.0.js ├── pages │ ├── Background │ │ └── index.js │ ├── Content │ │ ├── index.js │ │ ├── injected.js │ │ └── trezorContentScript.js │ ├── Popup │ │ ├── internalPopup.html │ │ └── mainPopup.html │ └── Tab │ │ ├── createWalletTab.html │ │ ├── hwTab.html │ │ └── trezorTx.html ├── test │ └── unit │ │ ├── api │ │ ├── extension │ │ │ └── index.test.js │ │ ├── util.test.js │ │ └── webpage │ │ │ └── eventRegistration.test.js │ │ └── migrations │ │ └── migration.test.js ├── ui │ ├── app.jsx │ ├── app │ │ ├── components │ │ │ ├── UpgradeModal.jsx │ │ │ ├── about.jsx │ │ │ ├── account.jsx │ │ │ ├── asset.jsx │ │ │ ├── assetBadge.jsx │ │ │ ├── assetPopover.jsx │ │ │ ├── assetPopoverDiff.jsx │ │ │ ├── assetsModal.jsx │ │ │ ├── assetsViewer.jsx │ │ │ ├── autocomplete.jsx │ │ │ ├── avatarLoader.jsx │ │ │ ├── changePasswordModal.jsx │ │ │ ├── collectible.jsx │ │ │ ├── collectiblesViewer.jsx │ │ │ ├── confirmModal.jsx │ │ │ ├── copy.jsx │ │ │ ├── historyViewer.jsx │ │ │ ├── privacyPolicy.jsx │ │ │ ├── qrCode.jsx │ │ │ ├── scrollbar.jsx │ │ │ ├── styles.css │ │ │ ├── termsOfUse.jsx │ │ │ ├── transaction.jsx │ │ │ ├── transactionBuilder.jsx │ │ │ ├── trezorWidget.jsx │ │ │ └── unitDisplay.jsx │ │ ├── pages │ │ │ ├── enable.jsx │ │ │ ├── noWallet.jsx │ │ │ ├── send.jsx │ │ │ ├── settings.jsx │ │ │ ├── signData.jsx │ │ │ ├── signTx.jsx │ │ │ ├── wallet.jsx │ │ │ └── welcome.jsx │ │ └── tabs │ │ │ ├── createWallet.jsx │ │ │ ├── hw.jsx │ │ │ └── trezorTx.jsx │ ├── index.jsx │ ├── indexInternal.jsx │ ├── indexMain.jsx │ ├── lace-migration │ │ ├── assets │ │ │ ├── arrow.svg │ │ │ ├── backpack.svg │ │ │ ├── checkmark.svg │ │ │ ├── chevron-left.svg │ │ │ ├── chevron-right.svg │ │ │ ├── clock.svg │ │ │ ├── done-dark.svg │ │ │ ├── done-white.svg │ │ │ ├── download.svg │ │ │ ├── features.svg │ │ │ ├── grouped-dark-mode.svg │ │ │ ├── grouped-white-mode.svg │ │ │ ├── lace-icon.svg │ │ │ ├── pending-dark-mode.svg │ │ │ └── pending-white-mode.svg │ │ └── components │ │ │ ├── all-done │ │ │ ├── all-done.component.jsx │ │ │ └── all-done.stories.js │ │ │ ├── almost-there │ │ │ ├── almost-there.component.jsx │ │ │ └── almost-there.stories.js │ │ │ ├── carousel │ │ │ ├── carousel.component.jsx │ │ │ ├── carousel.stories.jsx │ │ │ └── slides │ │ │ │ ├── Slide1.component.jsx │ │ │ │ ├── Slide1.stories.js │ │ │ │ ├── Slide2.component.jsx │ │ │ │ ├── Slide2.stories.js │ │ │ │ ├── Slide3.component.jsx │ │ │ │ └── Slide3.stories.js │ │ │ ├── dismiss-btn.jsx │ │ │ ├── get-color.js │ │ │ ├── index.js │ │ │ ├── migration-view │ │ │ ├── migration-view.component.jsx │ │ │ └── migration-view.stories.js │ │ │ ├── migration.component.jsx │ │ │ ├── no-wallet │ │ │ ├── no-wallet.component.jsx │ │ │ └── no-wallet.stories.js │ │ │ ├── slide.component.jsx │ │ │ └── text.component.jsx │ ├── store.jsx │ └── theme.jsx └── wasm │ └── cardano_message_signing │ ├── .gitignore │ ├── Cargo.lock │ ├── Cargo.toml │ ├── LICENSE │ ├── cardano_message_signing.generated.js │ ├── cardano_message_signing_bg.wasm │ ├── nodejs │ └── cardano_message_signing.generated.js │ └── src │ ├── builders.rs │ ├── cbor.rs │ ├── crypto.rs │ ├── error.rs │ ├── lib.rs │ ├── serialization.rs │ ├── tests.rs │ └── utils.rs ├── tsconfig.json ├── utils ├── build.js ├── env.js └── webserver.js └── webpack.config.js /.env.template: -------------------------------------------------------------------------------- 1 | # Copy this file as .env 2 | # It's used for authentication when uploading source maps. 3 | SENTRY_AUTH_TOKEN= 4 | SENTRY_ORG= 5 | SENTRY_PROJECT= 6 | SENTRY_DSN= 7 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "react-app", 3 | "globals": { 4 | "chrome": "readonly" 5 | }, 6 | "env": { 7 | "es2020": true, 8 | "browser": true, 9 | "node": true 10 | }, 11 | "parserOptions": { 12 | "babelOptions": { 13 | "presets": [ 14 | ["babel-preset-react-app", false], 15 | "babel-preset-react-app/prod" 16 | ] 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.github/issue-comments-validate.yml: -------------------------------------------------------------------------------- 1 | validates: 2 | - key: check1 3 | bodies: 4 | - "www" 5 | - ".com" 6 | - "dot com" 7 | - "http" 8 | - "https" 9 | - "support" 10 | - "Support" -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Test and Build 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | push: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | build: 13 | name: Build and test 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: actions/setup-node@v4 19 | with: 20 | node-version: 20 21 | - name: Install dependencies 22 | run: npm ci 23 | - name: Test 24 | run: npm test 25 | - name: Generates Secrets File 26 | run: | 27 | echo "export default { PROJECT_ID_MAINNET: '${{ secrets.PROJECT_ID_MAINNET }}', PROJECT_ID_PREVIEW: '${{ secrets.PROJECT_ID_PREVIEW }}', PROJECT_ID_PREPROD: '${{ secrets.PROJECT_ID_PREPROD }}', POSTHOG_API_KEY:'${{ secrets.POSTHOG_API_KEY }}', POSTHOG_PROJECT_ID: '${{ secrets.POSTHOG_PROJECT_ID }}', LACE_EXTENSION_ID: '${{ secrets.LACE_EXTENSION_ID }}'};" > secrets.production.js 28 | - name: Build 29 | run: npm run build 30 | env: 31 | SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} 32 | SENTRY_DSN: ${{ secrets.SENTRY_DSN }} 33 | SENTRY_ORG: ${{ vars.SENTRY_ORG }} 34 | SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }} 35 | - name: Upload build 36 | uses: actions/upload-artifact@v4 37 | with: 38 | name: build 39 | path: build 40 | -------------------------------------------------------------------------------- /.github/workflows/comments-moderation.yml: -------------------------------------------------------------------------------- 1 | name: Issue Comments Validate 2 | on: 3 | issue_comment: 4 | types: 5 | - created 6 | - edited 7 | 8 | jobs: 9 | issue_comment: 10 | name: Issue Comment 11 | if: ${{ !github.event.issue.pull_request }} 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Git Checkout 15 | id: git-checkout 16 | uses: actions/checkout@v2 17 | - name: Validate 18 | id: validate 19 | uses: fukuiretu/actions-issue-comment-validates@main 20 | with: 21 | debug: true 22 | github-token: ${{ secrets.GITHUB_TOKEN }} 23 | - name: Report 24 | id: report 25 | env: 26 | HTML_URL: ${{ github.event.comment.html_url }} 27 | ID: ${{ github.event.comment.id }} 28 | NUMBER: ${{ github.event.issue.number }} 29 | run: | 30 | echo "ISSUE_NUMBER=$NUMBER" >> "$GITHUB_OUTPUT" 31 | echo "Issue $NUMBER has had a comment (ID: $ID) with restricted words: ${{ steps.validate.outputs.check1 }}. Moderate at $HTML_URL if true" 32 | - name: Post Warning Comment 33 | id: post-warning-comment 34 | if: ${{ steps.validate.outputs.check1 == 'true' }} 35 | uses: peter-evans/create-or-update-comment@v3 36 | with: 37 | issue-number: ${{ steps.report.outputs.ISSUE_NUMBER }} 38 | body: | 39 | ## The previous comment MAY be from a malicious actor :warning: 40 | 41 | - Do not click **links** :x: 42 | - Do not reveal your **recovery phrase** (ever) :x: 43 | 44 | _This is an automated response aimed at protecting users from potential scams_ 45 | reactions: 'eyes' -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env 15 | .env.local 16 | .env.development.local 17 | .env.test.local 18 | .env.production.local 19 | .history 20 | 21 | # secrets 22 | secrets.production.js 23 | secrets.development.js 24 | 25 | # IDEs 26 | .idea 27 | 28 | *storybook.log -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | legacy-peer-deps=true 3 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v20.9.0 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "es5", 4 | "requirePragma": false, 5 | "arrowParens": "always" 6 | } -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { NormalModuleReplacementPlugin } = require('webpack'); 3 | 4 | const config = { 5 | stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], 6 | addons: [ 7 | '@storybook/addon-webpack5-compiler-swc', 8 | '@storybook/addon-onboarding', 9 | '@storybook/addon-links', 10 | '@storybook/addon-essentials', 11 | '@chromatic-com/storybook', 12 | '@storybook/addon-interactions', 13 | ], 14 | framework: { 15 | name: '@storybook/react-webpack5', 16 | options: {}, 17 | }, 18 | webpackFinal: webPackconfig => { 19 | const fileLoaderRule = webPackconfig.module.rules.find(rule => 20 | rule.test?.test('.svg'), 21 | ); 22 | fileLoaderRule.exclude = /\.svg$/; 23 | webPackconfig.module.rules.push({ 24 | test: /\.svg$/i, 25 | issuer: /\.[jt]sx?$/, 26 | use: [ 27 | { 28 | loader: '@svgr/webpack', 29 | options: { 30 | icon: true, 31 | exportType: 'named', 32 | }, 33 | }, 34 | ], 35 | }); 36 | webPackconfig.resolve.extensions.push('.svg'); 37 | if (webPackconfig.plugins) { 38 | webPackconfig.plugins.push( 39 | new NormalModuleReplacementPlugin( 40 | /^webextension-polyfill$/, 41 | path.join(__dirname, './mocks/webextension-polyfill.mock.ts'), 42 | ), 43 | ); 44 | } 45 | 46 | return webPackconfig; 47 | }, 48 | }; 49 | export default config; 50 | -------------------------------------------------------------------------------- /.storybook/mocks/webextension-polyfill.mock.ts: -------------------------------------------------------------------------------- 1 | export const runtime = {}; 2 | export const storage = {}; 3 | -------------------------------------------------------------------------------- /.storybook/preview.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box, DarkMode, Flex, LightMode } from '@chakra-ui/react'; 3 | import Theme from '../src/ui/theme'; 4 | 5 | const preview = { 6 | parameters: { 7 | controls: { 8 | matchers: { 9 | color: /(background|color)$/i, 10 | date: /Date$/i, 11 | }, 12 | }, 13 | }, 14 | }; 15 | 16 | export const decorators = [ 17 | (Story) => { 18 | return ( 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | ); 34 | }, 35 | ]; 36 | 37 | export default preview; 38 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | nodejs 20.9.0 2 | -------------------------------------------------------------------------------- /MessageSigning.md: -------------------------------------------------------------------------------- 1 | ```js 2 | const S = require('@emurgo/cardano-serialization-lib-nodejs'); //serialization-lib: https://github.com/Emurgo/cardano-serialization-lib 3 | const MS = require('./message-signing/rust/pkg/emurgo_message_signing'); //message-signing: https://github.com/Emurgo/message-signing/blob/master/examples/rust/src/main.rs 4 | 5 | // Example runs in Node.js (to verify in a browser, the libraries need to be imported asynchronously) 6 | 7 | /** 8 | * 9 | * @param {string} address - hex encoded 10 | * @param {string} payload - hex encoded 11 | * @param {string} coseSign1Hex - hex encoded 12 | */ 13 | const verify = (address, payload, coseSign1Hex) => { 14 | const coseSign1 = MS.COSESign1.from_bytes(Buffer.from(coseSign1Hex, 'hex')); 15 | const payloadCose = coseSign1.payload(); 16 | 17 | if (verifyPayload(payload, payloadCose)) 18 | throw new Error('Payload does not match'); 19 | 20 | const protectedHeaders = coseSign1 21 | .headers() 22 | .protected() 23 | .deserialized_headers(); 24 | const addressCose = S.Address.from_bytes( 25 | protectedHeaders.header(MS.Label.new_text('address')).as_bytes() 26 | ); 27 | 28 | // Commented out the below line in favor of CIP-30, only use if you are using the deprecated window.cardano.signedData(address, payload) 29 | //const publicKeyCose = S.PublicKey.from_bytes(protectedHeaders.key_id()); 30 | const key = MS.COSEKey.from_bytes( 31 | Buffer.from(coseKey, 'hex') 32 | ); 33 | const publicKeyBytes = key 34 | .header( 35 | MS.Label.new_int( 36 | MS.Int.new_negative( 37 | MS.BigNum.from_str('2') 38 | ) 39 | ) 40 | ) 41 | .as_bytes(); 42 | const publicKeyCose = 43 | APILoader.Cardano.PublicKey.from_bytes(publicKeyBytes); 44 | 45 | if (!verifyAddress(address, addressCose, publicKeyCose)) 46 | throw new Error('Could not verify because of address mismatch'); 47 | 48 | const signature = S.Ed25519Signature.from_bytes(coseSign1.signature()); 49 | const data = coseSign1.signed_data().to_bytes(); 50 | return publicKeyCose.verify(data, signature); 51 | }; 52 | 53 | const verifyPayload = (payload, payloadCose) => { 54 | return Buffer.from(payloadCose, 'hex').compare(Buffer.from(payload, 'hex')); 55 | }; 56 | 57 | const verifyAddress = (address, addressCose, publicKeyCose) => { 58 | const checkAddress = S.Address.from_bytes(Buffer.from(address, 'hex')); 59 | if (addressCose.to_bech32() !== checkAddress.to_bech32()) return false; 60 | // check if BaseAddress 61 | try { 62 | const baseAddress = S.BaseAddress.from_address(addressCose); 63 | //reconstruct address 64 | const paymentKeyHash = publicKeyCose.hash(); 65 | const stakeKeyHash = baseAddress.stake().as_pub_key(); 66 | const reconstructedAddress = S.BaseAddress.new( 67 | checkAddress.network_id(), 68 | S.Credential.new_pub_key(paymentKeyHash), 69 | S.Credential.new_pub_key(stakeKeyHash) 70 | ); 71 | if ( 72 | checkAddress.to_bech32() !== reconstructedAddress.to_address().to_bech32() 73 | ) 74 | return false; 75 | 76 | return true; 77 | } catch (e) {} 78 | // check if RewardAddress 79 | try { 80 | //reconstruct address 81 | const stakeKeyHash = publicKeyCose.hash(); 82 | const reconstructedAddress = S.RewardAddress.new( 83 | checkAddress.network_id(), 84 | S.Credential.new_pub_key(stakeKeyHash) 85 | ); 86 | if ( 87 | checkAddress.to_bech32() !== reconstructedAddress.to_address().to_bech32() 88 | ) 89 | return false; 90 | 91 | return true; 92 | } catch (e) {} 93 | return false; 94 | }; 95 | 96 | //test 97 | verify(
, , ) //: true or false 98 | 99 | ``` 100 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | Copyright 2024 IOHK 2 | 3 | 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.txt 4 | 5 | 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. -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { presets: ['@babel/preset-env'] }; 2 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | moduleNameMapper: { 3 | // mock out the browser version of WASM bindings with the nodejs bindings 4 | '@dcspark/cardano-multiplatform-lib-browser': 5 | '@dcspark/cardano-multiplatform-lib-nodejs', 6 | '^(.*)../wasm/cardano_message_signing/cardano_message_signing.generated(.*)$': 7 | '$1../wasm/cardano_message_signing/nodejs/cardano_message_signing.generated$2', 8 | // blockfrost keys 9 | secrets: '../../secrets.testing.js', 10 | }, 11 | transform: { 12 | '^.+\\.(ts|tsx)?$': 'ts-jest', 13 | '^.+\\.(js|jsx)$': 'babel-jest', 14 | }, 15 | transformIgnorePatterns: [ 16 | `/node_modules/(?!crypto-random-string)` 17 | ], 18 | setupFilesAfterEnv: ['./jest.setup.js'], 19 | }; 20 | -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | Object.assign(global, require('jest-chrome')); 2 | 3 | // mocking the chrome.storage.local API 4 | global.mockStore = {}; 5 | global.chrome.storage.local.get = (key, callback) => 6 | callback(key ? { [key]: global.mockStore[key] } : global.mockStore); 7 | global.chrome.storage.local.set = (item, callback) => { 8 | global.mockStore = { ...global.mockStore, ...item }; 9 | callback(); 10 | }; 11 | global.chrome.storage.local.clear = () => (global.mockStore = {}); 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nami-wallet", 3 | "version": "3.9.6", 4 | "description": "Maintained by IOG", 5 | "license": "Apache-2.0", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/input-output-hk/nami" 9 | }, 10 | "scripts": { 11 | "build": "node utils/build.js", 12 | "start": "node utils/webserver.js", 13 | "prettier": "prettier --write '**/*.{js,jsx,css,html}'", 14 | "test": "NODE_ENV=test jest", 15 | "storybook": "storybook dev -p 6006", 16 | "build-storybook": "storybook build" 17 | }, 18 | "dependencies": { 19 | "@blaze-cardano/sdk": "^0.1.32", 20 | "@cardano-foundation/ledgerjs-hw-app-cardano": "^7.1.3", 21 | "@cardano-sdk/core": "^0.39.2", 22 | "@chakra-ui/button": "^2.1.0", 23 | "@chakra-ui/checkbox": "^2.3.2", 24 | "@chakra-ui/css-reset": "^2.3.0", 25 | "@chakra-ui/icons": "^2.1.1", 26 | "@chakra-ui/react": "^2.8.2", 27 | "@dcspark/cardano-multiplatform-lib-browser": "^6.0.0", 28 | "@dcspark/milkomeda-constants": "^0.4.0", 29 | "@dicebear/avatars": "^4.6.4", 30 | "@dicebear/avatars-bottts-sprites": "^4.6.4", 31 | "@emotion/react": "^11.4.0", 32 | "@emotion/styled": "^11.3.0", 33 | "@emurgo/cip14-js": "^3.0.1", 34 | "@fontsource/ubuntu": "^5.0.8", 35 | "@ledgerhq/hw-transport-webusb": "^6.28.0", 36 | "@sentry/react": "^8.32.0", 37 | "@sentry/webpack-plugin": "^2.22.4", 38 | "@trezor/connect-web": "^9.3.0", 39 | "bip39": "^3.0.4", 40 | "cardano-hw-interop-lib": "^3.0.2", 41 | "crc": "^4.1.1", 42 | "crypto-random-string": "^5.0.0", 43 | "debounce": "^2.0.0", 44 | "debounce-promise": "^3.1.2", 45 | "easy-peasy": "^6.0.4", 46 | "framer-motion": "^4.1.16", 47 | "javascript-time-ago": "^2.3.7", 48 | "jest-chrome": "^0.8.0", 49 | "lodash": "^4.17.21", 50 | "match-sorter": "^6.1.0", 51 | "node-polyfill-webpack-plugin": "^2.0.1", 52 | "posthog-js": "^1.93.6", 53 | "promise-latest": "^1.0.4", 54 | "qr-code-styling": "^1.6.0-rc.0", 55 | "react": "^18.2.0", 56 | "react-custom-scrollbars-2": "^4.5.0", 57 | "react-dom": "^18.2.0", 58 | "react-icons": "4.2.0", 59 | "react-kawaii": "^0.18.0", 60 | "react-lazy-load-image-component": "^1.5.1", 61 | "react-middle-ellipsis": "^1.2.1", 62 | "react-number-format": "^5.3.1", 63 | "react-router-dom": "^6.18.0", 64 | "react-time-ago": "^7.0.0", 65 | "react-window": "^1.8.10", 66 | "rxjs": "^7.8.1", 67 | "styled-components": "^6.1.1", 68 | "use-constant": "^1.1.0", 69 | "web3-validator": "^2.0.3", 70 | "webextension-polyfill": "^0.12.0" 71 | }, 72 | "devDependencies": { 73 | "@babel/preset-env": "^7.23.8", 74 | "@chromatic-com/storybook": "^1.5.0", 75 | "@dcspark/cardano-multiplatform-lib-nodejs": "^6.0.0", 76 | "@pmmmwh/react-refresh-webpack-plugin": "^0.5.11", 77 | "@storybook/addon-essentials": "^8.1.6", 78 | "@storybook/addon-interactions": "^8.1.6", 79 | "@storybook/addon-links": "^8.1.6", 80 | "@storybook/addon-onboarding": "^8.1.6", 81 | "@storybook/addon-webpack5-compiler-swc": "^1.0.3", 82 | "@storybook/blocks": "^8.1.6", 83 | "@storybook/react": "^8.1.6", 84 | "@storybook/react-webpack5": "^8.1.6", 85 | "@storybook/test": "^8.1.6", 86 | "@svgr/webpack": "^8.1.0", 87 | "@swc/core": "^1.3.100", 88 | "@tsconfig/node20": "^20.1.2", 89 | "@types/jest": "^29.5.12", 90 | "@types/react": "^18.2.42", 91 | "@types/webextension-polyfill": "^0.10.7", 92 | "babel-eslint": "^10.1.0", 93 | "babel-jest": "^29.7.0", 94 | "babel-loader": "^9.1.3", 95 | "babel-preset-react-app": "^10.0.0", 96 | "clean-webpack-plugin": "^4.0.0", 97 | "copy-webpack-plugin": "^11.0.0", 98 | "css-loader": "^6.8.1", 99 | "dotenv": "^16.4.5", 100 | "dotenv-defaults": "^5.0.2", 101 | "eslint": "^8.54.0", 102 | "eslint-config-react-app": "^7.0.1", 103 | "eslint-plugin-flowtype": "^8.0.3", 104 | "eslint-plugin-import": "^2.22.1", 105 | "eslint-plugin-jsx-a11y": "^6.4.1", 106 | "eslint-plugin-react": "^7.22.0", 107 | "eslint-plugin-react-hooks": "^4.2.0", 108 | "eslint-plugin-storybook": "^0.8.0", 109 | "expose-loader": "^4.1.0", 110 | "file-loader": "^6.2.0", 111 | "fs-extra": "^11.1.1", 112 | "html-loader": "^4.2.0", 113 | "html-webpack-plugin": "^5.2.0", 114 | "jest": "^29.7.0", 115 | "jest-environment-jsdom": "^29.7.0", 116 | "jest-webextension-mock": "^3.9.0", 117 | "prettier": "^3.1.0", 118 | "react-refresh": "^0.14.0", 119 | "sass": "1.69.5", 120 | "sass-loader": "^13.3.2", 121 | "source-map-loader": "^4.0.1", 122 | "storybook": "^8.1.6", 123 | "style-loader": "^3.3.3", 124 | "swc-loader": "^0.2.3", 125 | "terser-webpack-plugin": "^5.1.1", 126 | "ts-jest": "^29.1.1", 127 | "ts-loader": "^9.5.1", 128 | "typescript": "^5.3.2", 129 | "webpack": "^5.88.1", 130 | "webpack-cli": "^5.1.4", 131 | "webpack-dev-server": "^4.15.1" 132 | }, 133 | "resolutions": { 134 | "nan": "2.17.0" 135 | }, 136 | "engines": { 137 | "node": ">=20.0.0" 138 | }, 139 | "eslintConfig": { 140 | "extends": [ 141 | "plugin:storybook/recommended" 142 | ] 143 | }, 144 | "packageManager": "yarn@1.22.21+sha1.1959a18351b811cdeedbd484a8f86c3cc3bbaf72" 145 | } 146 | -------------------------------------------------------------------------------- /secrets.testing.js: -------------------------------------------------------------------------------- 1 | export default { 2 | PROJECT_ID_MAINNET: 'DUMMY_MAINNET', 3 | PROJECT_ID_TESTNET: 'DUMMY_TESTNET', 4 | PROJECT_ID_PREVIEW: 'DUMMY_PREVIEW', 5 | PROJECT_ID_PREPROD: 'DUMMY_PREPROD', 6 | POSTHOG_API_KEY: 'DUMMY_POSTHOG_API_KEY', 7 | POSTHOG_PROJECT_ID: 'DUMMY_POSTHOG_PROJECT_ID', 8 | LACE_EXTENSION_ID: 'DUMMY_EXT_ID', 9 | }; 10 | -------------------------------------------------------------------------------- /src/api/loader.js: -------------------------------------------------------------------------------- 1 | import * as wasm from '@dcspark/cardano-multiplatform-lib-browser'; 2 | import * as wasm2 from '../wasm/cardano_message_signing/cardano_message_signing.generated'; 3 | 4 | /** 5 | * Loads the WASM modules 6 | */ 7 | 8 | class Loader { 9 | _wasm = wasm; 10 | 11 | /** 12 | * Instantiate message signing library. 13 | * Loader.Cardano is loaded synchronously and does not require async instantiation. 14 | */ 15 | async load() { 16 | if (this._wasm2) return; 17 | try { 18 | await wasm2.instantiate(); 19 | } catch (_e) { 20 | // Only happens when running with Jest (Node.js) 21 | } 22 | 23 | /** 24 | * @private 25 | */ 26 | this._wasm2 = wasm2; 27 | } 28 | 29 | get Cardano() { 30 | return this._wasm; 31 | } 32 | 33 | get Message() { 34 | return this._wasm2; 35 | } 36 | } 37 | 38 | export default new Loader(); 39 | -------------------------------------------------------------------------------- /src/api/migration-tool/cross-extension-messaging/nami-migration-client.extension.ts: -------------------------------------------------------------------------------- 1 | import { runtime, storage } from 'webextension-polyfill'; 2 | 3 | import { 4 | MigrationState, 5 | MIGRATION_KEY, 6 | DISMISS_MIGRATION_UNTIL, 7 | } from '../migrator/migration-state.data'; 8 | import { setMigrationToInProgress } from './nami/set-migration-to-in-progress'; 9 | import { LACE_EXTENSION_ID } from './nami/environment'; 10 | import { createNamiMigrationListener } from './nami/create-nami-migration-listener'; 11 | import { NamiMessages } from './shared/types'; 12 | import { awaitLacePongResponse } from './nami/await-lace-pong-response'; 13 | 14 | export const checkLaceInstallation = () => 15 | awaitLacePongResponse(LACE_EXTENSION_ID, runtime.sendMessage); 16 | 17 | export const enableMigration = () => { 18 | storage.local.remove(DISMISS_MIGRATION_UNTIL); 19 | return setMigrationToInProgress(storage.local); 20 | }; 21 | 22 | export const dismissMigration = ({ 23 | dismissMigrationUntil, 24 | }: { 25 | dismissMigrationUntil: number; 26 | }) => { 27 | storage.local.set({ 28 | [MIGRATION_KEY]: MigrationState.Dismissed, 29 | [DISMISS_MIGRATION_UNTIL]: dismissMigrationUntil, 30 | }); 31 | }; 32 | 33 | export const disableMigration = async () => { 34 | const date = new Date(); 35 | const timeInPast = date.setTime(date.getTime() - 1000); 36 | await storage.local.set({ 37 | [DISMISS_MIGRATION_UNTIL]: timeInPast, 38 | [MIGRATION_KEY]: MigrationState.None, 39 | }); 40 | }; 41 | 42 | export const handleLaceMigrationRequests = () => 43 | runtime.onMessageExternal.addListener( 44 | createNamiMigrationListener(LACE_EXTENSION_ID, storage.local) 45 | ); 46 | 47 | export const openLace = () => { 48 | runtime.sendMessage(LACE_EXTENSION_ID, NamiMessages.open); 49 | }; 50 | -------------------------------------------------------------------------------- /src/api/migration-tool/cross-extension-messaging/nami/await-lace-pong-response.ts: -------------------------------------------------------------------------------- 1 | import { NamiLacePingProtocol, SendMessageToExtension } from '../shared/types'; 2 | 3 | export const awaitLacePongResponse = async ( 4 | laceExtensionId: string, 5 | sendMessage: SendMessageToExtension, 6 | ): Promise => { 7 | console.log( 8 | '[MIGRATION TO LACE] Checking if Lace is installed by sending ping message', 9 | ); 10 | try { 11 | const response = await sendMessage( 12 | laceExtensionId, 13 | NamiLacePingProtocol.ping, 14 | ); 15 | console.log('[MIGRATION TO LACE] response from Lace', response); 16 | if (response === NamiLacePingProtocol.pong) { 17 | return true; 18 | } 19 | } catch { 20 | console.log('[MIGRATION TO LACE] got no response from Lace'); 21 | return false; 22 | } 23 | console.log('[MIGRATION TO LACE] got wrong pong response from Lace'); 24 | return false; 25 | }; 26 | -------------------------------------------------------------------------------- /src/api/migration-tool/cross-extension-messaging/nami/create-nami-migration-listener.spec.ts: -------------------------------------------------------------------------------- 1 | import { createNamiMigrationListener } from './create-nami-migration-listener'; 2 | import { 3 | MIGRATION_KEY, 4 | MigrationState, 5 | } from '../../migrator/migration-state.data'; 6 | import { State } from '../../migrator/nami-storage.data'; 7 | import { MigrationExceptions } from '../shared/exceptions'; 8 | import { fixture } from '../../migrator/nami-storage.fixture'; 9 | import { map } from '../../migrator/nami-storage-mapper.data'; 10 | import { createMockNamiStore } from '../shared/test-helpers'; 11 | 12 | describe('createNamiMigrationListener', () => { 13 | const LACE_ID = 'fakeLaceId'; 14 | 15 | it('should return undefined for messages from other extensions than Lace', async () => { 16 | const namiMigrationListener = createNamiMigrationListener( 17 | LACE_ID, 18 | createMockNamiStore(), 19 | ); 20 | expect( 21 | await namiMigrationListener( 22 | { type: 'status' }, 23 | { id: 'fakeOtherExtensionId' }, 24 | ), 25 | ).toBeUndefined(); 26 | }); 27 | 28 | it('should set migration state to none when receiving abort message from Lace', async () => { 29 | const store = createMockNamiStore(); 30 | const namiMigrationListener = createNamiMigrationListener(LACE_ID, store); 31 | const returnValue = await namiMigrationListener( 32 | { type: 'abort' }, 33 | { id: LACE_ID }, 34 | ); 35 | expect(returnValue).toBeUndefined(); 36 | expect(store.set).toHaveBeenCalledWith({ 37 | [MIGRATION_KEY]: MigrationState.None, 38 | }); 39 | }); 40 | 41 | it('should set migration state to completed when receiving completed message from Lace', async () => { 42 | const store = createMockNamiStore(); 43 | const namiMigrationListener = createNamiMigrationListener(LACE_ID, store); 44 | const returnValue = await namiMigrationListener( 45 | { type: 'completed' }, 46 | { id: LACE_ID }, 47 | ); 48 | expect(returnValue).toBeUndefined(); 49 | expect(store.set).toHaveBeenCalledWith({ 50 | [MIGRATION_KEY]: MigrationState.Completed, 51 | }); 52 | }); 53 | 54 | describe('Lace requesting status from Nami', () => { 55 | it('should return migration state none if no migration state has been saved', async () => { 56 | const store = createMockNamiStore(); 57 | const namiMigrationListener = createNamiMigrationListener(LACE_ID, store); 58 | const returnValue = await namiMigrationListener( 59 | { type: 'status' }, 60 | { id: LACE_ID }, 61 | ); 62 | expect(returnValue).toBe(MigrationState.None); 63 | }); 64 | it('should return the previously saved migration state', async () => { 65 | const savedState: Partial = { 66 | laceMigration: MigrationState.Completed, 67 | }; 68 | const store = createMockNamiStore(savedState); 69 | const namiMigrationListener = createNamiMigrationListener(LACE_ID, store); 70 | 71 | const returnValue = await namiMigrationListener( 72 | { type: 'status' }, 73 | { id: LACE_ID }, 74 | ); 75 | expect(returnValue).toBe(savedState.laceMigration); 76 | }); 77 | }); 78 | 79 | describe('Lace requesting data from Nami', () => { 80 | it('should throw an error if data cant be mapped', async () => { 81 | const store = createMockNamiStore({ 82 | laceMigration: MigrationState.InProgress, 83 | }); 84 | const namiMigrationListener = createNamiMigrationListener(LACE_ID, store); 85 | await expect( 86 | namiMigrationListener({ type: 'data' }, { id: LACE_ID }), 87 | ).rejects.toBe(MigrationExceptions.FailedToParse); 88 | }); 89 | 90 | it('should return mapped nami to migration state', async () => { 91 | const store = createMockNamiStore({ 92 | laceMigration: MigrationState.InProgress, 93 | ...fixture, 94 | }); 95 | const namiMigrationListener = createNamiMigrationListener(LACE_ID, store); 96 | await expect( 97 | namiMigrationListener({ type: 'data' }, { id: LACE_ID }), 98 | ).resolves.toEqual(map(fixture)); 99 | }); 100 | 101 | it('should return the data also if migration was already completed in Nami', async () => { 102 | const store = createMockNamiStore({ 103 | laceMigration: MigrationState.Completed, 104 | ...fixture, 105 | }); 106 | const namiMigrationListener = createNamiMigrationListener(LACE_ID, store); 107 | await expect( 108 | namiMigrationListener({ type: 'data' }, { id: LACE_ID }), 109 | ).resolves.toEqual(map(fixture)); 110 | }); 111 | }); 112 | }); 113 | -------------------------------------------------------------------------------- /src/api/migration-tool/cross-extension-messaging/nami/create-nami-migration-listener.ts: -------------------------------------------------------------------------------- 1 | import { 2 | MIGRATION_KEY, 3 | MigrationState, 4 | } from '../../migrator/migration-state.data'; 5 | import { MigrationExceptions } from '../shared/exceptions'; 6 | import * as Nami from '../../migrator/nami-storage.data'; 7 | import { map } from '../../migrator/nami-storage-mapper.data'; 8 | import * as Migration from '../../migrator/migration-data.data'; 9 | import { LaceMessages, MessageSender, NamiStore } from '../shared/types'; 10 | 11 | // Creates a runtime.onMessageExternal event listener as documented here 12 | // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/runtime/onMessageExternal#addlistener_syntax 13 | export const createNamiMigrationListener = 14 | (laceExtensionId: string, store: NamiStore) => 15 | async ( 16 | message: LaceMessages, 17 | sender: MessageSender, 18 | ): Promise => { 19 | console.log( 20 | '[NAMI MIGRATION] createNamiMigrationListener', 21 | message, 22 | sender, 23 | ); 24 | if (sender.id !== laceExtensionId) return undefined; 25 | 26 | if (message.type === 'abort') { 27 | await store.set({ 28 | [MIGRATION_KEY]: MigrationState.None, 29 | }); 30 | return; 31 | } 32 | 33 | if (message.type === 'completed') { 34 | await store.set({ 35 | [MIGRATION_KEY]: MigrationState.Completed, 36 | }); 37 | return; 38 | } 39 | 40 | const data: Partial = await store.get(); 41 | 42 | if (message.type === 'status') { 43 | return data?.laceMigration ?? MigrationState.None; 44 | } 45 | 46 | if (message.type === 'data') { 47 | try { 48 | return map(data); 49 | } catch { 50 | return Promise.reject(MigrationExceptions.FailedToParse); 51 | } 52 | } 53 | }; 54 | -------------------------------------------------------------------------------- /src/api/migration-tool/cross-extension-messaging/nami/environment.ts: -------------------------------------------------------------------------------- 1 | import secrets from '../../../../config/provider'; 2 | 3 | if (secrets.LACE_EXTENSION_ID === undefined) { 4 | throw new Error('LACE_EXTENSION_ID must be defined'); 5 | } 6 | export const LACE_EXTENSION_ID = secrets.LACE_EXTENSION_ID; 7 | -------------------------------------------------------------------------------- /src/api/migration-tool/cross-extension-messaging/nami/set-migration-to-in-progress.ts: -------------------------------------------------------------------------------- 1 | import { 2 | MIGRATION_KEY, 3 | MigrationState, 4 | } from '../../migrator/migration-state.data'; 5 | import { NamiStore } from '../shared/types'; 6 | 7 | export const setMigrationToInProgress = (store: NamiStore) => { 8 | return store.set({ [MIGRATION_KEY]: MigrationState.InProgress }); 9 | }; 10 | -------------------------------------------------------------------------------- /src/api/migration-tool/cross-extension-messaging/shared/exceptions.ts: -------------------------------------------------------------------------------- 1 | export enum MigrationExceptions { 2 | NotInProgress = 'not-in-progress', 3 | FailedToParse = 'failed-to-parse', 4 | } 5 | -------------------------------------------------------------------------------- /src/api/migration-tool/cross-extension-messaging/shared/test-helpers.ts: -------------------------------------------------------------------------------- 1 | import { State } from '../../migrator/nami-storage.data'; 2 | 3 | export const createMockNamiStore = (mockedState: Partial = {}) => { 4 | const store = { 5 | set: jest.fn(), 6 | get: jest.fn(), 7 | }; 8 | store.get.mockResolvedValue(mockedState); 9 | return store; 10 | }; 11 | -------------------------------------------------------------------------------- /src/api/migration-tool/cross-extension-messaging/shared/types.ts: -------------------------------------------------------------------------------- 1 | import * as Nami from '../../migrator/nami-storage.data'; 2 | 3 | export interface MessageSender { 4 | id?: string; 5 | } 6 | 7 | export type SendMessageToExtension = ( 8 | id: string, 9 | message: unknown, 10 | ) => Promise; 11 | 12 | export enum NamiLacePingProtocol { 13 | ping = 'ping', 14 | pong = 'pong', 15 | } 16 | 17 | export type NamiStore = { 18 | set: (value: Partial) => Promise; 19 | get: () => Promise>; 20 | }; 21 | 22 | export type LaceMessages = 23 | | { 24 | type: 'status'; 25 | } 26 | | { 27 | type: 'data'; 28 | } 29 | | { 30 | type: 'abort'; 31 | } 32 | | { 33 | type: 'completed'; 34 | }; 35 | 36 | export enum NamiMessages { 37 | 'open' = 'open', 38 | } 39 | -------------------------------------------------------------------------------- /src/api/migration-tool/migrator/migration-data.data.ts: -------------------------------------------------------------------------------- 1 | export interface State { 2 | encryptedPrivateKey: string; 3 | accounts: Account[]; 4 | hardwareWallets: HarwareWallet[]; 5 | dapps: string[]; 6 | currency: 'usd' | 'eur'; 7 | analytics: Analytics; 8 | themeColor: string; 9 | } 10 | 11 | export interface Account { 12 | index: number; 13 | name: string; 14 | extendedAccountPublicKey: string; 15 | collaterals: Record; 16 | paymentAddresses: Record; 17 | } 18 | 19 | export interface HarwareWallet extends Account { 20 | vendor: 'ledger' | 'trezor'; 21 | } 22 | 23 | export interface Collateral { 24 | lovelace: string; 25 | tx: { 26 | hash: string; 27 | index: number; 28 | }; 29 | } 30 | 31 | export type Networks = 'mainnet' | 'preview' | 'preprod'; 32 | 33 | export interface Analytics { 34 | enabled: boolean; 35 | userId: string; 36 | } 37 | -------------------------------------------------------------------------------- /src/api/migration-tool/migrator/migration-state.data.ts: -------------------------------------------------------------------------------- 1 | export enum MigrationState { 2 | None = 'none', 3 | InProgress = 'in-progress', 4 | Completed = 'completed', 5 | Dismissed = 'dismissed', 6 | Dormant = 'dormant', // only available to set during canary phase 7 | } 8 | 9 | export const MIGRATION_KEY = 'laceMigration' as const; 10 | export const DISMISS_MIGRATION_UNTIL = 'dismissMigrationUntil' as const; 11 | -------------------------------------------------------------------------------- /src/api/migration-tool/migrator/nami-storage-mapper.data.ts: -------------------------------------------------------------------------------- 1 | import * as Nami from './nami-storage.data'; 2 | import { 3 | Account, 4 | Collateral, 5 | HarwareWallet, 6 | Networks, 7 | State, 8 | } from './migration-data.data'; 9 | 10 | const mapCollateral = (network: Nami.NetworkInfo): Collateral | undefined => { 11 | if (!network.collateral) return undefined; 12 | return { 13 | lovelace: network.collateral.lovelace!, 14 | tx: { 15 | hash: network.collateral.txHash!, 16 | index: network.collateral.txId!, 17 | }, 18 | }; 19 | }; 20 | 21 | export const map = (namiState: Partial): State => { 22 | if (namiState.encryptedKey === undefined || namiState.userId === undefined) { 23 | throw new Error('provided nami state is not correct'); 24 | } 25 | const namiAccounts = Object.entries(namiState.accounts!); 26 | 27 | const accounts: Account[] = []; 28 | const hardwareWallets: HarwareWallet[] = []; 29 | 30 | while (namiAccounts.length > 0) { 31 | const [key, account] = namiAccounts.pop()!; 32 | 33 | const collaterals: Record = { 34 | mainnet: mapCollateral(account.mainnet), 35 | preview: mapCollateral(account.preview), 36 | preprod: mapCollateral(account.preprod), 37 | }; 38 | 39 | const paymentAddresses: Record = { 40 | mainnet: account.mainnet.paymentAddr, 41 | preview: account.preview.paymentAddr, 42 | preprod: account.preprod.paymentAddr, 43 | }; 44 | 45 | if (key.startsWith('ledger') || key.startsWith('trezor')) { 46 | const [type, _, index] = key.split('-'); 47 | hardwareWallets.push({ 48 | index: parseInt(index), 49 | name: account.name, 50 | extendedAccountPublicKey: account.publicKey, 51 | collaterals, 52 | vendor: type === 'ledger' ? 'ledger' : 'trezor', 53 | paymentAddresses, 54 | }); 55 | } else { 56 | accounts.push({ 57 | index: parseInt(key), 58 | name: account.name, 59 | extendedAccountPublicKey: account.publicKey, 60 | collaterals, 61 | paymentAddresses, 62 | }); 63 | } 64 | } 65 | 66 | accounts.sort((a, b) => a.index - b.index); 67 | hardwareWallets.sort((a, b) => a.index - b.index); 68 | 69 | return { 70 | encryptedPrivateKey: namiState.encryptedKey, 71 | accounts, 72 | hardwareWallets, 73 | dapps: namiState.whitelisted || [], 74 | currency: namiState.currency === 'usd' ? 'usd' : 'eur', 75 | analytics: { 76 | enabled: Boolean(namiState.analytics), 77 | userId: namiState.userId, 78 | }, 79 | themeColor: namiState.themeColor || 'light', 80 | }; 81 | }; 82 | -------------------------------------------------------------------------------- /src/api/migration-tool/migrator/nami-storage-mapper.spec.ts: -------------------------------------------------------------------------------- 1 | import { map } from './nami-storage-mapper.data'; 2 | import { fixture } from './nami-storage.fixture'; 3 | 4 | test('map nami storage to migration payload', async () => { 5 | const migrationPayload = map(fixture); 6 | 7 | expect(migrationPayload).toEqual({ 8 | encryptedPrivateKey: 9 | 'da7af7c22eeaf4bb460c02b426792d556a9242a7e8dca47e7628350f4290d97a0078f3dad5812606f4fa993772dc1c0fc5b7941dc91796a111a8d789b8d3f473eb9c67b32f89f5a2518ff02bb5595a00638e99e7799858b42a639edab14d1bd997eeb8ebe518939ca0522a527219062d1585bfd34434dd84c2a3f895d34863158fce54c1c2c2ab8e8fd3d23d70bc3114fd302badca2c850160597443', 10 | accounts: [ 11 | { 12 | index: 0, 13 | name: 'Nami', 14 | extendedAccountPublicKey: 15 | 'a5f18f73dde7b6f11df448913d60a86bbb397a435269e5024193b293f28892fd33d1225d468aac8f5a9d3cfedceacabe80192fcf0beb5c5c9b7988151f3353cc', 16 | collaterals: { 17 | mainnet: undefined, 18 | preview: undefined, 19 | preprod: undefined, 20 | }, 21 | paymentAddresses: { 22 | mainnet: 23 | 'addr1qymlvzkhufx0f94vqtfsmfa7yzxtwql8ga8aq5yk6u98gn593a82g73tm9n0l6vehusxn3fxwwxhrssgmvnwnlaa6p4qw7srzg', 24 | preprod: 25 | 'addr_test1qqmlvzkhufx0f94vqtfsmfa7yzxtwql8ga8aq5yk6u98gn593a82g73tm9n0l6vehusxn3fxwwxhrssgmvnwnlaa6p4qdgdrwh', 26 | preview: 27 | 'addr_test1qqmlvzkhufx0f94vqtfsmfa7yzxtwql8ga8aq5yk6u98gn593a82g73tm9n0l6vehusxn3fxwwxhrssgmvnwnlaa6p4qdgdrwh', 28 | }, 29 | }, 30 | { 31 | index: 1, 32 | name: 'xxx', 33 | extendedAccountPublicKey: 34 | '5280ef1287dfa35605891eb788590dbfe43b59682ada939ee111f8667d4a0847b43c08b5dce7aab937e860626e95f05ef6cc12758fa9ee16a4fc394bd9f684e4', 35 | collaterals: { 36 | mainnet: undefined, 37 | preview: undefined, 38 | preprod: undefined, 39 | }, 40 | paymentAddresses: { 41 | mainnet: 42 | 'addr1qxaqtjxrdnaxm2a74r0xtd0a5jkg3nd9zdzuvy7yc5a67qgte8c6lysmrq57uy7hgq9daj5sylgttehecjjrmgn0n2nsgwaxqs', 43 | preprod: 44 | 'addr_test1qzaqtjxrdnaxm2a74r0xtd0a5jkg3nd9zdzuvy7yc5a67qgte8c6lysmrq57uy7hgq9daj5sylgttehecjjrmgn0n2nstcqxv0', 45 | preview: 46 | 'addr_test1qzaqtjxrdnaxm2a74r0xtd0a5jkg3nd9zdzuvy7yc5a67qgte8c6lysmrq57uy7hgq9daj5sylgttehecjjrmgn0n2nstcqxv0', 47 | }, 48 | }, 49 | ], 50 | hardwareWallets: [ 51 | { 52 | index: 0, 53 | name: 'Ledger 1', 54 | extendedAccountPublicKey: 55 | '7eefc2120ec17dc280f7f7adba233bcd75c00d59d9442ded45e44e00745e28d4d06673111ee5aad359f25fafbb787c55f1f80e0d9f0b567959d0a3587276210c', 56 | collaterals: { 57 | mainnet: undefined, 58 | preview: { 59 | lovelace: '5000000', 60 | tx: { 61 | hash: '5aeae083cceb3a930f3402d367096d2d524d03abf915fd9452cc59b3063a6aad', 62 | index: 0, 63 | }, 64 | }, 65 | preprod: undefined, 66 | }, 67 | paymentAddresses: { 68 | mainnet: 69 | 'addr1q85g8x8lpu6ydhe5ytggl6vrlt6prtdzzk7nljh6uf23ktknjausjhrf7f92l3ze9pp5njhkwrv45phhryjxjhtmq7rqnmrrjv', 70 | preprod: 71 | 'addr_test1qr5g8x8lpu6ydhe5ytggl6vrlt6prtdzzk7nljh6uf23ktknjausjhrf7f92l3ze9pp5njhkwrv45phhryjxjhtmq7rqsd7r7n', 72 | preview: 73 | 'addr_test1qr5g8x8lpu6ydhe5ytggl6vrlt6prtdzzk7nljh6uf23ktknjausjhrf7f92l3ze9pp5njhkwrv45phhryjxjhtmq7rqsd7r7n', 74 | }, 75 | vendor: 'ledger', 76 | }, 77 | { 78 | index: 1, 79 | name: 'Ledger 2', 80 | extendedAccountPublicKey: 81 | '18b35d8e07c1dd096ce359f4ce5ac669a27c8ac23583f9e6a53b7508efd28c849a7b1eda5ac98ed02d6048d0cbe84f91570b9f0cc3acff935cf229cd798da730', 82 | collaterals: { 83 | mainnet: undefined, 84 | preview: undefined, 85 | preprod: undefined, 86 | }, 87 | paymentAddresses: { 88 | mainnet: 89 | 'addr1qyhxhljsyfuzv4f2dyaxpn6why7eher90twkcyjvpyzmm27wxzqczmxjrtvpm39rjcqj4tqt2vjwt3a4t92ujuwes4jqxx3xgd', 90 | preprod: 91 | 'addr_test1qqhxhljsyfuzv4f2dyaxpn6why7eher90twkcyjvpyzmm27wxzqczmxjrtvpm39rjcqj4tqt2vjwt3a4t92ujuwes4jq9svxyj', 92 | preview: 93 | 'addr_test1qqhxhljsyfuzv4f2dyaxpn6why7eher90twkcyjvpyzmm27wxzqczmxjrtvpm39rjcqj4tqt2vjwt3a4t92ujuwes4jq9svxyj', 94 | }, 95 | vendor: 'ledger', 96 | }, 97 | ], 98 | dapps: ['https://preview.handle.me'], 99 | currency: 'usd', 100 | analytics: { 101 | enabled: true, 102 | userId: 103 | 'b60f45ed66f596ebfd2ca19ff704cfee33e316795da50f295fc1f85d6ddf539c', 104 | }, 105 | themeColor: 'light', 106 | }); 107 | }); 108 | -------------------------------------------------------------------------------- /src/api/migration-tool/migrator/nami-storage.data.ts: -------------------------------------------------------------------------------- 1 | import { MigrationState } from './migration-state.data'; 2 | 3 | interface Asset { 4 | decimals?: number | null; 5 | has_nft_onchain_metadata?: boolean; 6 | quantity?: string; 7 | unit?: string; 8 | } 9 | 10 | interface BlockDetail { 11 | block_vrf?: string; 12 | confirmations?: number; 13 | epoch?: number; 14 | epoch_slot?: number; 15 | fees?: string; 16 | hash?: string; 17 | height?: number; 18 | next_block?: string; 19 | op_cert?: string; 20 | op_cert_counter?: string; 21 | output?: string; 22 | previous_block?: string; 23 | size?: number; 24 | slot?: number; 25 | slot_leader?: string; 26 | time?: number; 27 | tx_count?: number; 28 | } 29 | 30 | interface TransactionInfo { 31 | asset_mint_or_burn_count?: number; 32 | block?: string; 33 | block_height?: number; 34 | block_time?: number; 35 | delegation_count?: number; 36 | deposit?: string; 37 | fees?: string; 38 | hash?: string; 39 | index?: number; 40 | invalid_before?: null | string; 41 | invalid_hereafter?: string; 42 | mir_cert_count?: number; 43 | output_amount?: Array<{ 44 | quantity?: string; 45 | unit?: string; 46 | }>; 47 | pool_retire_count?: number; 48 | pool_update_count?: number; 49 | redeemer_count?: number; 50 | size?: number; 51 | slot?: number; 52 | stake_cert_count?: number; 53 | utxo_count?: number; 54 | valid_contract?: boolean; 55 | withdrawal_count?: number; 56 | } 57 | 58 | interface Utxo { 59 | address?: string; 60 | amount?: Array<{ 61 | quantity?: string; 62 | unit?: string; 63 | }>; 64 | collateral?: boolean; 65 | data_hash?: null | string; 66 | inline_datum?: null | string; 67 | output_index?: number; 68 | reference?: boolean; 69 | reference_script_hash?: null | string; 70 | tx_hash?: string; 71 | } 72 | 73 | interface TransactionUtxos { 74 | hash?: string; 75 | inputs?: Utxo[]; 76 | outputs?: Utxo[]; 77 | } 78 | 79 | interface HistoryDetail { 80 | block?: BlockDetail; 81 | info?: TransactionInfo; 82 | utxos?: TransactionUtxos; 83 | } 84 | 85 | interface TxHistory { 86 | confirmed?: string[]; 87 | details?: Record; 88 | } 89 | 90 | export interface NetworkInfo { 91 | assets?: Asset[]; 92 | history?: TxHistory; 93 | lovelace?: string | null; 94 | minAda?: number | string; 95 | paymentAddr: string; 96 | rewardAddr?: string; 97 | collateral?: { 98 | lovelace?: string; 99 | txHash?: string; 100 | txId?: number; 101 | }; 102 | lastUpdate?: string; 103 | } 104 | 105 | export interface Account { 106 | avatar?: string; 107 | index: number | string; 108 | mainnet: NetworkInfo; 109 | name: string; 110 | paymentKeyHash?: string; 111 | paymentKeyHashBech32?: string; 112 | preprod: NetworkInfo; 113 | preview: NetworkInfo; 114 | publicKey: string; 115 | stakeKeyHash?: string; 116 | testnet: NetworkInfo; 117 | } 118 | 119 | interface Migration { 120 | completed?: string[]; 121 | version?: string; 122 | } 123 | 124 | interface Network { 125 | id?: string; 126 | node?: string; 127 | } 128 | 129 | export interface State { 130 | acceptedLegalDocsVersion?: number; 131 | accounts?: Record; 132 | analytics?: boolean; 133 | currency?: string; 134 | currentAccount?: string; 135 | encryptedKey: string; 136 | migration?: Migration; 137 | network?: Network; 138 | userId: string; 139 | whitelisted?: string[]; 140 | laceMigration?: MigrationState; 141 | themeColor?: string; 142 | } 143 | -------------------------------------------------------------------------------- /src/api/webpage/eventRegistration.js: -------------------------------------------------------------------------------- 1 | import { TARGET } from '../../config/config'; 2 | 3 | /** 4 | * @param {string} eventName 5 | * @param {Function} callback 6 | */ 7 | export const on = (eventName, callback) => { 8 | const handler = (event) => callback(event.detail); 9 | 10 | const events = window.cardano.nami._events[eventName] || []; 11 | 12 | window.cardano.nami._events[eventName] = [...events, [callback, handler]]; 13 | 14 | window.addEventListener(`${TARGET}${eventName}`, handler); 15 | }; 16 | 17 | /** 18 | * @param {string} eventName 19 | * @param {Function} callback 20 | */ 21 | export const off = (eventName, callback) => { 22 | const filterHandlersBy = (predicate) => (handlers) => 23 | handlers.filter(([savedCallback]) => predicate(savedCallback)); 24 | 25 | const filterByMatchingHandlers = filterHandlersBy((cb) => cb === callback); 26 | const filterByNonMatchingHandlers = filterHandlersBy((cb) => cb !== callback); 27 | 28 | const eventHandlers = window.cardano.nami._events[eventName]; 29 | 30 | if (typeof eventHandlers !== 'undefined') { 31 | const matchingHandlers = filterByMatchingHandlers(eventHandlers); 32 | 33 | for (const [, handler] of matchingHandlers) { 34 | window.removeEventListener(`${TARGET}${eventName}`, handler); 35 | } 36 | 37 | window.cardano.nami._events[eventName] = 38 | filterByNonMatchingHandlers(eventHandlers); 39 | } 40 | }; 41 | -------------------------------------------------------------------------------- /src/api/webpage/index.js: -------------------------------------------------------------------------------- 1 | import { METHOD } from '../../config/config'; 2 | import { Messaging } from '../messaging'; 3 | 4 | export const getBalance = async () => { 5 | const result = await Messaging.sendToContent({ method: METHOD.getBalance }); 6 | return result.data; 7 | }; 8 | 9 | export const enable = async () => { 10 | const result = await Messaging.sendToContent({ method: METHOD.enable }); 11 | return result.data; 12 | }; 13 | 14 | export const isEnabled = async () => { 15 | const result = await Messaging.sendToContent({ method: METHOD.isEnabled }); 16 | return result.data; 17 | }; 18 | 19 | //deprecated soon 20 | export const signData = async (address, payload) => { 21 | const result = await Messaging.sendToContent({ 22 | method: METHOD.signData, 23 | data: { address, payload }, 24 | }); 25 | return result.data; 26 | }; 27 | 28 | export const signDataCIP30 = async (address, payload) => { 29 | const result = await Messaging.sendToContent({ 30 | method: METHOD.signData, 31 | data: { address, payload, CIP30: true }, 32 | }); 33 | return result.data; 34 | }; 35 | 36 | export const signTx = async (tx, partialSign = false) => { 37 | const result = await Messaging.sendToContent({ 38 | method: METHOD.signTx, 39 | data: { tx, partialSign }, 40 | }); 41 | return result.data; 42 | }; 43 | 44 | export const getAddress = async () => { 45 | const result = await Messaging.sendToContent({ 46 | method: METHOD.getAddress, 47 | }); 48 | return result.data; 49 | }; 50 | 51 | export const getRewardAddress = async () => { 52 | const result = await Messaging.sendToContent({ 53 | method: METHOD.getRewardAddress, 54 | }); 55 | return result.data; 56 | }; 57 | 58 | export const getNetworkId = async () => { 59 | const result = await Messaging.sendToContent({ 60 | method: METHOD.getNetworkId, 61 | }); 62 | return result.data; 63 | }; 64 | 65 | export const getUtxos = async (amount = undefined, paginate = undefined) => { 66 | const result = await Messaging.sendToContent({ 67 | method: METHOD.getUtxos, 68 | data: { amount, paginate }, 69 | }); 70 | return result.data; 71 | }; 72 | 73 | export const getCollateral = async () => { 74 | const result = await Messaging.sendToContent({ 75 | method: METHOD.getCollateral, 76 | }); 77 | return result.data; 78 | }; 79 | 80 | export const submitTx = async (tx) => { 81 | const result = await Messaging.sendToContent({ 82 | method: METHOD.submitTx, 83 | data: tx, 84 | }); 85 | return result.data; 86 | }; 87 | 88 | export { on, off } from './eventRegistration'; 89 | -------------------------------------------------------------------------------- /src/assets/img/Nami.ai: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/input-output-hk/nami/e52e0bdb02eb1ec224db26f48b0192685a30f99e/src/assets/img/Nami.ai -------------------------------------------------------------------------------- /src/assets/img/ada.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/input-output-hk/nami/e52e0bdb02eb1ec224db26f48b0192685a30f99e/src/assets/img/ada.png -------------------------------------------------------------------------------- /src/assets/img/bannerBlack.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/img/bannerWhite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/img/icon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/input-output-hk/nami/e52e0bdb02eb1ec224db26f48b0192685a30f99e/src/assets/img/icon-128.png -------------------------------------------------------------------------------- /src/assets/img/icon-34.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/input-output-hk/nami/e52e0bdb02eb1ec224db26f48b0192685a30f99e/src/assets/img/icon-34.png -------------------------------------------------------------------------------- /src/assets/img/ledgerLogo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 36 | 41 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 106 | 107 | -------------------------------------------------------------------------------- /src/assets/img/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/img/logoWhite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/img/trezorLogo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/config/provider.js: -------------------------------------------------------------------------------- 1 | import { NODE } from './config'; 2 | import secrets from 'secrets'; 3 | import { version } from '../../package.json'; 4 | 5 | const networkToProjectId = { 6 | mainnet: secrets.PROJECT_ID_MAINNET, 7 | testnet: secrets.PROJECT_ID_TESTNET, 8 | preprod: secrets.PROJECT_ID_PREPROD, 9 | preview: secrets.PROJECT_ID_PREVIEW, 10 | }; 11 | 12 | export default { 13 | api: { 14 | ipfs: 'https://ipfs.blockfrost.dev/ipfs', 15 | base: (node = NODE.mainnet) => node, 16 | header: { [secrets.NAMI_HEADER || 'dummy']: version }, 17 | key: (network = 'mainnet') => ({ 18 | project_id: networkToProjectId[network], 19 | }), 20 | price: (currency = 'usd') => 21 | fetch( 22 | `https://api.coingecko.com/api/v3/simple/price?ids=cardano&vs_currencies=${currency}` 23 | ) 24 | .then((res) => res.json()) 25 | .then((res) => res.cardano[currency]), 26 | }, 27 | posthog_api_token: secrets.POSTHOG_API_KEY, 28 | posthog_project_id: secrets.POSTHOG_PROJECT_ID, 29 | LACE_EXTENSION_ID: secrets.LACE_EXTENSION_ID, 30 | }; 31 | -------------------------------------------------------------------------------- /src/features/analytics/config.ts: -------------------------------------------------------------------------------- 1 | import secrets from '../../config/provider'; 2 | 3 | export const PUBLIC_POSTHOG_HOST = 'https://eu.posthog.com'; 4 | 5 | export const PRODUCTION_TRACKING_MODE_ENABLED = 'true'; 6 | 7 | export const POSTHOG_API_KEY = secrets.posthog_api_token; 8 | export const POSTHOG_PROJECT_ID = secrets.posthog_project_id; 9 | -------------------------------------------------------------------------------- /src/features/analytics/event-tracker.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import debounce from 'debounce'; 3 | import { useCaptureEvent } from './hooks'; 4 | import { Events } from './events'; 5 | 6 | const PAGE_VIEW_DEBOUNCE_DELAY = 1000; 7 | 8 | const debouncedPageView = debounce((fn) => fn(), PAGE_VIEW_DEBOUNCE_DELAY); 9 | 10 | export const EventTracker = () => { 11 | const capture = useCaptureEvent(); 12 | 13 | // Track page changes with PostHog in order to keep the user session alive 14 | useEffect(() => { 15 | const trackActivePageChange = () => 16 | debouncedPageView(() => { 17 | capture(Events.PageView); 18 | }); 19 | 20 | window.addEventListener('load', trackActivePageChange); 21 | window.addEventListener('popstate', trackActivePageChange); 22 | 23 | trackActivePageChange(); 24 | 25 | return () => { 26 | window.removeEventListener('load', trackActivePageChange); 27 | window.removeEventListener('popstate', trackActivePageChange); 28 | }; 29 | }, [capture]); 30 | 31 | return null; 32 | }; 33 | -------------------------------------------------------------------------------- /src/features/analytics/hooks.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import { usePostHog } from 'posthog-js/react'; 3 | import { Events, Properties } from './events'; 4 | import { getAnalyticsConsent, getEventMetadata } from './services'; 5 | import { useAnalyticsContext } from './provider'; 6 | 7 | export const useCaptureEvent = () => { 8 | const posthog = usePostHog(); 9 | const [analytics] = useAnalyticsContext(); 10 | 11 | const captureEvent = useCallback( 12 | async (event: Events, properties: Properties = {}) => { 13 | const [hasConsent, metadata] = await Promise.all([ 14 | getAnalyticsConsent(), 15 | getEventMetadata(analytics.view), 16 | ]); 17 | 18 | if (posthog && hasConsent) { 19 | posthog.capture(event, { 20 | ...properties, 21 | ...metadata, 22 | }); 23 | } 24 | }, 25 | [posthog, analytics.view] 26 | ); 27 | 28 | return captureEvent; 29 | }; 30 | -------------------------------------------------------------------------------- /src/features/analytics/posthog.ts: -------------------------------------------------------------------------------- 1 | import { PostHogConfig } from 'posthog-js'; 2 | import { PUBLIC_POSTHOG_HOST } from './config'; 3 | 4 | export const getOptions = (userId: string): Partial => ({ 5 | request_batching: false, 6 | api_host: PUBLIC_POSTHOG_HOST, 7 | autocapture: false, 8 | disable_session_recording: true, 9 | capture_pageview: false, 10 | capture_pageleave: false, 11 | disable_compression: true, 12 | // Disables PostHog user ID persistence - we manage ID ourselves with userIdService 13 | disable_persistence: true, 14 | disable_cookie: true, 15 | persistence: 'memory', 16 | bootstrap: { 17 | distinctID: userId, 18 | isIdentifiedID: true, 19 | }, 20 | property_blacklist: [ 21 | '$autocapture_disabled_server_side', 22 | '$console_log_recording_enabled_server_side', 23 | '$device_id', 24 | '$session_recording_recorder_version_server_side', 25 | '$time', 26 | ], 27 | }); 28 | -------------------------------------------------------------------------------- /src/features/analytics/provider.tsx: -------------------------------------------------------------------------------- 1 | import { PostHogProvider } from 'posthog-js/react'; 2 | import { PostHogConfig } from 'posthog-js'; 3 | import React, { 4 | ReactNode, 5 | useMemo, 6 | createContext, 7 | useContext, 8 | useState, 9 | useEffect, 10 | } from 'react'; 11 | import { getOptions } from './posthog'; 12 | import { ExtensionViews } from './types'; 13 | import { POSTHOG_API_KEY } from './config'; 14 | import { 15 | getAnalyticsConsent, 16 | getUserId, 17 | setAnalyticsConsent, 18 | } from './services'; 19 | import { FeatureFlagProvider } from '../feature-flags/provider'; 20 | 21 | /** 22 | * Represents the user's consent to tracking analytics events 23 | * as well as the current extension view for tracked events 24 | */ 25 | interface AnalyticsState { 26 | consent?: boolean; 27 | userId?: string; 28 | view: ExtensionViews; 29 | } 30 | 31 | /** 32 | * Provides access to the AnalyticsState and handling 33 | * the storage of userId and consent in chrome.storage.local 34 | */ 35 | const useAnalyticsState = ( 36 | view: ExtensionViews 37 | ): [AnalyticsState, (consent: boolean) => Promise] => { 38 | // Store the consent in React state to trigger component updates 39 | const [consentState, setConsentState] = useState({ 40 | view, 41 | }); 42 | 43 | // Fetch the stored user consent and assign to React state 44 | useEffect(() => { 45 | (async function () { 46 | const [consent, userId] = await Promise.all([ 47 | getAnalyticsConsent(), 48 | getUserId(), 49 | ]); 50 | setConsentState({ 51 | consent, 52 | userId, 53 | view, 54 | }); 55 | })(); 56 | }, []); 57 | 58 | return [ 59 | consentState, 60 | async (consent) => { 61 | // Allow to set the consent state and store it too 62 | await setAnalyticsConsent(consent); 63 | setConsentState({ 64 | consent, 65 | userId: consentState.userId, 66 | view, 67 | }); 68 | }, 69 | ]; 70 | }; 71 | 72 | /** 73 | * The analytics React context which is exposed by the hook below 74 | */ 75 | const AnalyticsContext = createContext | null>(null); 78 | 79 | /** 80 | * The public hook that should be used by components to interact with the 81 | * analytics state. 82 | */ 83 | export const useAnalyticsContext = () => { 84 | const analyticsContext = useContext(AnalyticsContext); 85 | if (analyticsContext === null) throw new Error('context not defined'); 86 | return analyticsContext; 87 | }; 88 | 89 | /** 90 | * The analytics provider that wraps the current extension 91 | * view to set up the PostHog provider and the API to interact 92 | * with the analytics state. 93 | */ 94 | export const AnalyticsProvider = ({ 95 | children, 96 | view, 97 | }: { 98 | children: ReactNode; 99 | view: ExtensionViews; 100 | }) => { 101 | const [analyticsState, setAnalyticsConsent] = useAnalyticsState(view); 102 | 103 | const options = useMemo | undefined>(() => { 104 | const id = analyticsState?.userId; 105 | 106 | if (id === undefined) { 107 | return undefined; 108 | } 109 | 110 | return getOptions(id); 111 | }, [analyticsState?.userId]); 112 | 113 | if (options === undefined) { 114 | // avoid rendering everything twice when waiting for Posthog options being fetched 115 | return null; 116 | } 117 | 118 | return ( 119 | 120 | 121 | 124 | {children} 125 | 126 | 127 | 128 | ); 129 | }; 130 | -------------------------------------------------------------------------------- /src/features/analytics/services.ts: -------------------------------------------------------------------------------- 1 | import { getStorage, setStorage } from '../../api/extension'; 2 | import { STORAGE } from '../../config/config'; 3 | import cryptoRandomString from 'crypto-random-string'; 4 | import { POSTHOG_PROJECT_ID, PRODUCTION_TRACKING_MODE_ENABLED } from './config'; 5 | import { ExtensionViews, PostHogMetadata } from './types'; 6 | 7 | export const getAnalyticsConsent = (): Promise => 8 | getStorage(STORAGE.analyticsConsent); 9 | 10 | export const setAnalyticsConsent = (consent: boolean): Promise => 11 | setStorage({ [STORAGE.analyticsConsent]: consent }); 12 | 13 | let userIdCache: string | undefined; 14 | 15 | export const getUserId = async (): Promise => { 16 | if (userIdCache) { 17 | return userIdCache; 18 | } 19 | 20 | const userId = await getStorage(STORAGE.userId); 21 | 22 | if (userId === undefined) { 23 | const newUserId = cryptoRandomString({ length: 64 }); 24 | 25 | await setStorage({ 26 | [STORAGE.userId]: newUserId, 27 | }); 28 | 29 | userIdCache = newUserId; 30 | return newUserId; 31 | } 32 | 33 | userIdCache = userId; 34 | 35 | return userId; 36 | }; 37 | 38 | const getNetwork = (): Promise<{ id: 'mainnet' | 'preprod' | 'preview' }> => 39 | getStorage(STORAGE.network); 40 | 41 | export const getEventMetadata = async ( 42 | view: ExtensionViews 43 | ): Promise => { 44 | const [userId, currentNetwork] = await Promise.all([ 45 | getUserId(), 46 | getNetwork(), 47 | ]); 48 | 49 | return { 50 | view: view, 51 | sent_at_local: new Date().toISOString(), 52 | distinct_id: userId, 53 | posthog_project_id: POSTHOG_PROJECT_ID, 54 | network: currentNetwork?.id, 55 | }; 56 | }; 57 | -------------------------------------------------------------------------------- /src/features/analytics/types.ts: -------------------------------------------------------------------------------- 1 | export enum ExtensionViews { 2 | Extended = 'extended', 3 | Popup = 'popup', 4 | } 5 | 6 | export type PostHogMetadata = { 7 | distinct_id?: string; 8 | alias_id?: string; 9 | view: ExtensionViews; 10 | sent_at_local: string; 11 | posthog_project_id: number; 12 | network: 'mainnet' | 'preprod' | 'preview'; 13 | }; 14 | -------------------------------------------------------------------------------- /src/features/analytics/ui/AnalyticsConsentModal.jsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@chakra-ui/button'; 2 | import { 3 | Modal, 4 | ModalBody, 5 | ModalCloseButton, 6 | ModalContent, 7 | ModalFooter, 8 | ModalHeader, 9 | ModalOverlay, 10 | } from '@chakra-ui/modal'; 11 | import { Link, Text } from '@chakra-ui/layout'; 12 | import React from 'react'; 13 | import PrivacyPolicy from '../../../ui/app/components/privacyPolicy'; 14 | 15 | export const AnalyticsConsentModal = ({ askForConsent, setConsent }) => { 16 | const privacyPolRef = React.useRef(); 17 | 18 | return ( 19 | <> 20 | setConsent(false)} 25 | blockScrollOnMount={false} 26 | closeOnOverlayClick={false} 27 | > 28 | 29 | 30 | Legal & Analytics 31 | 32 | 38 | Give us a hand to improve your Nami experience 39 | 40 | 41 | We would like to collect anonymous information from your browser 42 | extension to help us improve the quality and performance of Nami. 43 | This may include data about how you use our service, your 44 | preferences and information about your system. You can always 45 | opt-out (see the  46 | window.open('https://www.namiwallet.io/')} 48 | textDecoration="underline" 49 | > 50 | FAQ 51 | 52 |  for more details). For more information on our privacy 53 | practices, see our  54 | privacyPolRef.current.openModal()} 56 | textDecoration="underline" 57 | > 58 | Privacy Policy 59 | 60 | . 61 | 62 | 63 | 64 | 67 | 70 | 71 | 72 | 73 | 74 | 75 | ); 76 | }; 77 | -------------------------------------------------------------------------------- /src/features/feature-flags/provider.tsx: -------------------------------------------------------------------------------- 1 | import { EarlyAccessFeature } from 'posthog-js'; 2 | import { usePostHog, useActiveFeatureFlags } from 'posthog-js/react'; 3 | import React, { 4 | ReactNode, 5 | createContext, 6 | useContext, 7 | useEffect, 8 | useState, 9 | } from 'react'; 10 | 11 | type MigrationFlag = { 12 | dismissInterval: number; // seconds to add to current time until migration prompt appears again 13 | dismissable: true; // can we dismiss the migration 14 | }; 15 | 16 | type FeatureFlags = { 17 | ['is-migration-active']?: MigrationFlag; 18 | [key: string]: unknown; 19 | }; 20 | 21 | /** 22 | * The feature flag React context which is exposed by the hook below 23 | */ 24 | export const FeatureFlagContext = createContext<{ 25 | featureFlags?: FeatureFlags; 26 | earlyAccessFeatures?: EarlyAccessFeature[]; 27 | isFFLoaded?: boolean; 28 | }>({}); 29 | 30 | /** 31 | * The public hook that should be used by components to interact with the 32 | * feature flags. 33 | */ 34 | export const useFeatureFlagsContext = () => { 35 | const featureFlagContext = useContext(FeatureFlagContext); 36 | if (featureFlagContext === null) 37 | throw new Error('feature flag context not defined'); 38 | return featureFlagContext; 39 | }; 40 | 41 | /** 42 | * The feature flag provider is separated from analytics 43 | * to provide the ability to read flags without the need 44 | * to accept analytics tracking. 45 | */ 46 | export const FeatureFlagProvider = ({ children }: { children: ReactNode }) => { 47 | const [featureFlags, setFeatureFlags] = useState(); 48 | const [isFFLoaded, setIsFFLoaded] = useState(false); 49 | const [earlyAccessFeatures, setEarlyAccessFeatures] = 50 | useState(); 51 | const posthog = usePostHog(); 52 | const activeFeatureFlags = useActiveFeatureFlags(); 53 | 54 | useEffect(() => { 55 | posthog.getEarlyAccessFeatures((features) => { 56 | setEarlyAccessFeatures(features); 57 | }); 58 | }, []); 59 | 60 | useEffect(() => { 61 | posthog.onFeatureFlags(() => setIsFFLoaded(true)); 62 | }); 63 | 64 | useEffect(() => { 65 | let enabledFlags: FeatureFlags = {}; 66 | activeFeatureFlags?.forEach((flagName) => { 67 | const isEnabled = posthog.isFeatureEnabled(flagName); 68 | if (isEnabled) { 69 | const payload = posthog.getFeatureFlagPayload(flagName); 70 | if (payload) { 71 | enabledFlags[flagName] = payload; 72 | } else { 73 | enabledFlags[flagName] = true; 74 | } 75 | } 76 | }); 77 | setFeatureFlags(enabledFlags); 78 | }, [activeFeatureFlags, posthog]); 79 | 80 | return ( 81 | 84 | {children} 85 | 86 | ); 87 | }; 88 | -------------------------------------------------------------------------------- /src/features/sentry.js: -------------------------------------------------------------------------------- 1 | import * as Sentry from '@sentry/react'; 2 | 3 | Sentry.init({ 4 | environment: process.env.NODE_ENV, 5 | dsn: process.env.SENTRY_DSN, 6 | integrations: [ 7 | Sentry.browserTracingIntegration(), 8 | Sentry.browserProfilingIntegration(), 9 | Sentry.replayIntegration(), 10 | ], 11 | // Set `tracePropagationTargets` to control for which URLs trace propagation should be enabled 12 | tracePropagationTargets: [ 13 | 'localhost', 14 | 'chrome-extension://lpfcbjknijpeeillifnkikgncikgfhdo', 15 | ], 16 | // .5% 17 | tracesSampleRate: 0.05, 18 | profilesSampleRate: 0.05, 19 | // Since profilesSampleRate is relative to tracesSampleRate, 20 | // the final profiling rate can be computed as tracesSampleRate * profilesSampleRate 21 | // A tracesSampleRate of 0.05 and profilesSampleRate of 0.05 results in 2.5% of 22 | // transactions being profiled (0.05*0.05=0.0025) 23 | 24 | // Capture Replay for 0.05% of all sessions, 25 | replaysSessionSampleRate: 0.005, 26 | // ...plus for 100% of sessions with an error 27 | replaysOnErrorSampleRate: 1.0, 28 | }); 29 | -------------------------------------------------------------------------------- /src/features/settings/legal/LegalSettings.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Button, 4 | Flex, 5 | Link, 6 | Popover, 7 | PopoverArrow, 8 | PopoverBody, 9 | PopoverContent, 10 | PopoverTrigger, 11 | Spacer, 12 | Switch, 13 | Text, 14 | Tooltip, 15 | } from '@chakra-ui/react'; 16 | import React, { useRef } from 'react'; 17 | import { ChevronRightIcon, InfoOutlineIcon } from '@chakra-ui/icons'; 18 | import PrivacyPolicy from '../../../ui/app/components/privacyPolicy'; 19 | import TermsOfUse from '../../../ui/app/components/termsOfUse'; 20 | import { useAnalyticsContext } from '../../analytics/provider'; 21 | 22 | export const LegalSettings = () => { 23 | const [analytics, setAnalyticsConsent] = useAnalyticsContext(); 24 | const termsRef = useRef<{ openModal: () => void }>(); 25 | const privacyPolicyRef = useRef<{ openModal: () => void }>(); 26 | return ( 27 | <> 28 | 29 | 30 | Legal 31 | 32 | 33 | 34 | 35 | Analytics 36 | 37 | 38 | 46 | 47 | 48 | 49 | 50 | 56 | We collect anonymous information from your browser extension 57 | to help us improve the quality and performance of Nami. This 58 | may include data about how you use our service, your 59 | preferences and information about your system. Read more  60 | window.open('https://namiwallet.io')} 62 | textDecoration="underline" 63 | > 64 | here 65 | 66 | . 67 | 68 | 69 | 70 | 71 | 72 | 73 | setAnalyticsConsent(!analytics.consent)} 76 | /> 77 | 78 | 79 | 88 | 89 | 98 | 99 | 100 | 101 | ); 102 | }; 103 | -------------------------------------------------------------------------------- /src/features/terms-and-privacy/config.ts: -------------------------------------------------------------------------------- 1 | export const CURRENT_VERSION = 1; 2 | -------------------------------------------------------------------------------- /src/features/terms-and-privacy/hooks.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from 'react'; 2 | import { 3 | getAcceptedLegalDocsVersion, 4 | setAcceptedLegalDocsVersion, 5 | } from './services'; 6 | import { CURRENT_VERSION } from './config'; 7 | 8 | export const useAcceptDocs = () => { 9 | const [accepted, setAccepted] = useState(false); 10 | 11 | return { 12 | accepted, 13 | setAccepted: useCallback( 14 | (accepted: boolean) => { 15 | setAccepted(accepted); 16 | setAcceptedLegalDocsVersion(accepted ? CURRENT_VERSION : undefined); 17 | }, 18 | [setAccepted] 19 | ), 20 | }; 21 | }; 22 | 23 | export const useShowUpdatePrompt = () => { 24 | const [shouldShowUpdatePrompt, setShouldShowUpdatePrompt] = useState( 25 | undefined 26 | ); 27 | 28 | useEffect(() => { 29 | const init = async () => { 30 | const acceptedVersion = await getAcceptedLegalDocsVersion(); 31 | 32 | if (acceptedVersion) { 33 | setShouldShowUpdatePrompt(acceptedVersion < CURRENT_VERSION); 34 | } else { 35 | setShouldShowUpdatePrompt(true); 36 | } 37 | }; 38 | 39 | init(); 40 | }, []); 41 | 42 | return { 43 | shouldShowUpdatePrompt, 44 | hideUpdatePrompt: useCallback(() => { 45 | setShouldShowUpdatePrompt(false); 46 | }, [setShouldShowUpdatePrompt]), 47 | }; 48 | }; 49 | -------------------------------------------------------------------------------- /src/features/terms-and-privacy/index.ts: -------------------------------------------------------------------------------- 1 | export { TermsAndPrivacyProvider } from './provider'; 2 | -------------------------------------------------------------------------------- /src/features/terms-and-privacy/provider.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from 'react'; 2 | import { TermsAndPrivacyModal } from './ui/TermsAndPrivacyModal'; 3 | import { useShowUpdatePrompt } from './hooks'; 4 | 5 | interface Props { 6 | children: ReactNode; 7 | } 8 | 9 | export const TermsAndPrivacyProvider = ({ children }: Props) => { 10 | const { shouldShowUpdatePrompt, hideUpdatePrompt } = useShowUpdatePrompt(); 11 | 12 | if (shouldShowUpdatePrompt === undefined) { 13 | return; 14 | } 15 | 16 | return shouldShowUpdatePrompt ? ( 17 | 18 | ) : ( 19 | children 20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /src/features/terms-and-privacy/services.ts: -------------------------------------------------------------------------------- 1 | import { getStorage, removeStorage, setStorage } from '../../api/extension'; 2 | import { STORAGE } from '../../config/config'; 3 | 4 | export const getAcceptedLegalDocsVersion = async (): Promise< 5 | number | undefined 6 | > => { 7 | const version = await getStorage(STORAGE.acceptedLegalDocsVersion); 8 | 9 | return version ? Number(version) : undefined; 10 | }; 11 | 12 | export const setAcceptedLegalDocsVersion = ( 13 | version: number | undefined 14 | ): Promise => { 15 | return version 16 | ? setStorage({ [STORAGE.acceptedLegalDocsVersion]: version }) 17 | : removeStorage(STORAGE.acceptedLegalDocsVersion); 18 | }; 19 | -------------------------------------------------------------------------------- /src/features/terms-and-privacy/ui/TermsAndPrivacyModal.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@chakra-ui/button'; 2 | import { 3 | Modal, 4 | ModalBody, 5 | ModalContent, 6 | ModalFooter, 7 | ModalHeader, 8 | ModalOverlay, 9 | } from '@chakra-ui/modal'; 10 | import { Checkbox } from '@chakra-ui/checkbox'; 11 | import { Link, Text, Box } from '@chakra-ui/layout'; 12 | import React, { useRef } from 'react'; 13 | import PrivacyPolicy from '../../../ui/app/components/privacyPolicy'; 14 | import TermsOfUse from '../../../ui/app/components/termsOfUse'; 15 | import { useAcceptDocs } from '../hooks'; 16 | 17 | interface Props { 18 | onContinue: () => void; 19 | } 20 | 21 | export const TermsAndPrivacyModal = ({ onContinue }: Props) => { 22 | const termsRef = useRef<{ openModal: () => void }>(); 23 | const privacyPolicyRef = useRef<{ openModal: () => void }>(); 24 | const { accepted, setAccepted } = useAcceptDocs(); 25 | 26 | return ( 27 | <> 28 | void 0} 33 | blockScrollOnMount={false} 34 | > 35 | 36 | 37 | 38 | Terms of use and Privacy Policy 39 | 40 | 41 | 42 | 43 | The terms of use and privacy policy have been updated. 44 | 45 | 46 | setAccepted(e.target.checked)} /> 47 | 48 | 49 | I read and accepted the{' '} 50 | termsRef.current?.openModal()} 52 | textDecoration="underline" 53 | > 54 | Terms of use 55 | 56 | and 57 | privacyPolicyRef.current?.openModal()} 59 | textDecoration="underline" 60 | > 61 | Privacy Policy 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | ); 83 | }; 84 | -------------------------------------------------------------------------------- /src/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "Nami", 4 | "version": "3.9.6", 5 | "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoy3Y6s5Q72zsN6+sBJL8EBCyGL/USyXXjTJGIIV/3xfcrkoZ53I+o9B0Euo8yz2GXnBjm3+ZIC1YwN5ZPC/uQpPHyq8GLksu3VvDdupKMNIWXvMByLjF0pyD4YNdNueU4r3fWPPBsbvG98cmNbeZ1NmwV2Byad4PQtUMx76jSk6KHcp3qNTMEo8utY49EIPaC9wr/Fg9gaqI83SFKNoe2FeKrb1HXaTT366myKLupHXm7hoD7U87a2itNfR7kohBoO6RBlrTNmYuq65wYO1eY5h/4tCkZrztjTKjfMWbuVc0kjL8VFv2IR1ETV8dMDgaBKxWGpccaDzF2GJfYNjK2QIDAQAB", 6 | "description": "Maintained by IOG", 7 | "background": { "service_worker": "background.bundle.js" }, 8 | "action": { 9 | "default_popup": "mainPopup.html", 10 | "default_icon": "icon-34.png" 11 | }, 12 | "icons": { 13 | "128": "icon-128.png" 14 | }, 15 | "content_scripts": [ 16 | { 17 | "matches": ["http://*/*", "https://*/*", ""], 18 | "js": ["contentScript.bundle.js"], 19 | "run_at": "document_start" 20 | }, 21 | { 22 | "matches": ["*://connect.trezor.io/*/popup.html*"], 23 | "js": ["trezorContentScript.bundle.js"], 24 | "all_frames": true 25 | } 26 | ], 27 | "web_accessible_resources": [ 28 | { 29 | "resources": [ 30 | "icon-128.png", 31 | "icon-34.png", 32 | "injected.bundle.js", 33 | "internalPopup.html" 34 | ], 35 | "matches": ["http://*/*", "https://*/*", "file://*/*"] 36 | } 37 | ], 38 | "content_security_policy": { 39 | "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; frame-src https://connect.trezor.io/;" 40 | }, 41 | "permissions": ["storage", "unlimitedStorage", "favicon"] 42 | } 43 | -------------------------------------------------------------------------------- /src/migrations/versions/1.0.0.js: -------------------------------------------------------------------------------- 1 | const migration = { 2 | version: '1.0.0', 3 | up: (pwd) => { 4 | // Upgrade logic here 5 | }, 6 | down: (pwd) => { 7 | // Downgrade logic here 8 | }, 9 | info: [ 10 | { 11 | title: 'Feature #1', 12 | detail: 'This new feature allow the user to see the Feature #1', 13 | }, 14 | { title: 'Bug fix: Feature #2', detail: null }, 15 | { 16 | title: 'Feature #3', 17 | detail: 'Allow to send feature #3 assets to multiple addresses', 18 | }, 19 | { title: 'Feature #2', detail: null }, 20 | { 21 | title: 'Feature #3 - Suspendisse eget nibh', 22 | detail: 23 | 'Donec consectetur enim in urna consectetur fringilla rhoncus mattis quam.', 24 | }, 25 | ], 26 | pwdRequired: true, 27 | }; 28 | 29 | export default migration; 30 | -------------------------------------------------------------------------------- /src/migrations/versions/1.1.5.js: -------------------------------------------------------------------------------- 1 | import { NETWORK_ID, STORAGE } from '../../config/config'; 2 | import { 3 | decryptWithPassword, 4 | getStorage, 5 | setStorage, 6 | } from '../../api/extension/index'; 7 | import { initTx } from '../../api/extension/wallet'; 8 | import Loader from '../../api/loader'; 9 | import { assetsToValue } from '../../api/util'; 10 | 11 | const harden = (num) => { 12 | return 0x80000000 + num; 13 | }; 14 | 15 | const migration = { 16 | version: '1.1.5', 17 | up: async (pwd) => { 18 | await Loader.load(); 19 | const protocolParameters = await initTx(); 20 | const networks = Object.keys(NETWORK_ID); 21 | const storage = await getStorage(STORAGE.accounts); 22 | const accounts = Object.keys(storage); 23 | 24 | // add minAda 25 | for (let i = 0; i < accounts.length; i++) { 26 | for (let j = 0; j < networks.length; j++) { 27 | if (storage[accounts[i]][networks[j]]) { 28 | const currentAccountNetwork = storage[accounts[i]][networks[j]]; 29 | let assets = currentAccountNetwork.assets; 30 | if (assets.length > 0) { 31 | const amount = await assetsToValue(assets); 32 | currentAccountNetwork.minAda = Loader.Cardano.min_ada_required( 33 | amount, 34 | BigInt(protocolParameters.minUtxo) 35 | ).toString(); 36 | } else { 37 | currentAccountNetwork.minAda = 0; 38 | } 39 | } 40 | } 41 | } 42 | 43 | //add public key 44 | const encryptedKey = await getStorage(STORAGE.encryptedKey); 45 | const decryptedKey = await decryptWithPassword(pwd, encryptedKey); 46 | let privateKey = Loader.Cardano.Bip32PrivateKey.from_raw_bytes( 47 | Buffer.from(decryptedKey, 'hex') 48 | ); 49 | 50 | Object.keys(storage).forEach(async (index) => { 51 | const account = storage[index]; 52 | account.publicKey = Buffer.from( 53 | privateKey 54 | .derive(harden(1852)) 55 | .derive(harden(1815)) 56 | .derive(harden(parseInt(account.index))) 57 | .to_public() 58 | .as_bytes() 59 | ).toString('hex'); 60 | }); 61 | 62 | privateKey.free(); 63 | privateKey = null; 64 | 65 | await setStorage({ [STORAGE.accounts]: storage }); 66 | }, 67 | down: async (pwd) => { 68 | const networks = Object.keys(NETWORK_ID); 69 | let storage = await getStorage(STORAGE.accounts); 70 | const accounts = Object.keys(storage); 71 | 72 | for (let i = 0; i < accounts.length; i++) { 73 | for (let j = 0; j < networks.length; j++) { 74 | if ( 75 | storage[accounts[i]][networks[j]] && 76 | storage[accounts[i]][networks[j]].minAda 77 | ) { 78 | delete storage[accounts[i]][networks[j]].minAda; 79 | } 80 | } 81 | } 82 | await setStorage({ [STORAGE.accounts]: storage }); 83 | }, 84 | info: [ 85 | { 86 | title: 'Show only spendable Ada', 87 | detail: 88 | 'In previous version, the wallet was showing the complete Ada balance. This lead the user to think that all Ada were available for spending. Native Assets (ie: NFT) require a small amount of Ada to be locked with them at all time. Locked Ada are now hidden.', 89 | }, 90 | { 91 | title: 'Bug fix: Inconsistent Balance', 92 | detail: 93 | 'Balance was reported many times to be out of sync. The wallet now tries to fetch the balance until it succeeds.', 94 | }, 95 | { 96 | title: 'Bug fix: CoinSelection', 97 | detail: 98 | 'The underlying algorithm managing UTxO set was behaving inconsistently, making it impossible to send out certain asset amounts.', 99 | }, 100 | ], 101 | pwdRequired: true, 102 | }; 103 | 104 | export default migration; 105 | -------------------------------------------------------------------------------- /src/migrations/versions/1.1.7.js: -------------------------------------------------------------------------------- 1 | const migration = { 2 | version: '1.1.7', 3 | up: async () => {}, 4 | down: async () => {}, 5 | info: [ 6 | { 7 | title: 'Bug fix: Inconsistent Balance', 8 | detail: 9 | 'Balance was reported many times to be out of sync, even with the latest fix. The logic to decide whether an update is needed or not has been reworked.', 10 | }, 11 | { 12 | title: 'Bug fix: Coin Selection', 13 | detail: 14 | 'The Coin Selection algorithm failed calculating properly requirements for some UTxO set, thus making impossible to send amount within a certain range.', 15 | }, 16 | { 17 | title: 'Bug fix: Blank Screen', 18 | detail: 19 | 'A blank screen was reported a few times. The causing issue was being able to press the Confirm button multiple times.', 20 | }, 21 | ], 22 | pwdRequired: false, 23 | }; 24 | 25 | export default migration; 26 | -------------------------------------------------------------------------------- /src/migrations/versions/2.0.0.js: -------------------------------------------------------------------------------- 1 | const migration = { 2 | version: '2.0.0', 3 | up: async () => {}, 4 | down: async () => {}, 5 | info: [ 6 | { 7 | title: 'Added collateral', 8 | detail: 9 | 'A new option "Collateral" was added to the menu. You can enable it in order to interact with smart contracts.', 10 | }, 11 | { 12 | title: 'Bug fix: ADA input amount', 13 | detail: 14 | 'Input box is highlighted in red correctly again, when amount is below minimum or above account balance.', 15 | }, 16 | { 17 | title: 'Bug fix: Coin selection', 18 | detail: 'Fixed bug, where small amounts could not be sent.', 19 | }, 20 | ], 21 | pwdRequired: false, 22 | }; 23 | 24 | export default migration; 25 | -------------------------------------------------------------------------------- /src/migrations/versions/2.1.0.js: -------------------------------------------------------------------------------- 1 | const migration = { 2 | version: '2.1.0', 3 | up: async () => {}, 4 | down: async () => {}, 5 | info: [ 6 | { 7 | title: 'Bug fix: Collateral', 8 | detail: 9 | 'Fixed issue with setting collateral amount, where user had to try it multiple times.', 10 | }, 11 | { 12 | title: 'Enhancement: Mempool checking', 13 | detail: 14 | 'Checking now if the mempool is full and letting the user know if he needs to resubmit the transaction.', 15 | }, 16 | { 17 | title: 'Enhancement: Coin selection', 18 | detail: 'Allowing now to send a lot of small UTxOs.', 19 | }, 20 | { 21 | title: 'Enhancement: Addresses', 22 | detail: 23 | 'Preventing users from sending to a contract address. Improved checking, if the user interacts with a contract under the sign screen.', 24 | }, 25 | ], 26 | pwdRequired: false, 27 | }; 28 | 29 | export default migration; 30 | -------------------------------------------------------------------------------- /src/migrations/versions/2.2.0.js: -------------------------------------------------------------------------------- 1 | const migration = { 2 | version: '2.2.0', 3 | up: async () => {}, 4 | down: async () => {}, 5 | info: [ 6 | { 7 | title: 'HW support', 8 | detail: 9 | 'Nami has now full hardware wallet support for Ledger and Trezor.', 10 | }, 11 | ], 12 | pwdRequired: false, 13 | }; 14 | 15 | export default migration; 16 | -------------------------------------------------------------------------------- /src/migrations/versions/2.3.0.js: -------------------------------------------------------------------------------- 1 | import { getStorage, setStorage } from '../../api/extension'; 2 | import { NETWORK_ID, STORAGE } from '../../config/config'; 3 | 4 | const migration = { 5 | version: '2.3.0', 6 | up: async () => { 7 | const accounts = await getStorage(STORAGE.accounts); 8 | Object.keys(accounts).forEach((accountIndex) => { 9 | accounts[accountIndex][NETWORK_ID.mainnet].forceUpdate = true; 10 | accounts[accountIndex][NETWORK_ID.testnet].forceUpdate = true; 11 | }); 12 | await setStorage({ [STORAGE.accounts]: accounts }); 13 | }, 14 | down: async () => {}, 15 | info: [ 16 | { 17 | title: 'Seperated assets view', 18 | detail: 'Nami now separates collectibles/NFTs and normal assets (FTs)', 19 | }, 20 | { 21 | title: 'Improved Coin selection', 22 | }, 23 | { 24 | title: 'Small bug fixing', 25 | }, 26 | { 27 | title: 'Customizable avatar for account', 28 | }, 29 | ], 30 | pwdRequired: false, 31 | }; 32 | 33 | export default migration; 34 | -------------------------------------------------------------------------------- /src/migrations/versions/2.3.2.js: -------------------------------------------------------------------------------- 1 | const migration = { 2 | version: '2.3.2', 3 | up: async () => {}, 4 | down: async () => {}, 5 | info: [ 6 | { 7 | title: 'Deregistration of stake keys', 8 | }, 9 | { 10 | title: 'Improved Inputs', 11 | detail: 'Number inputs are automatically formatted now.', 12 | }, 13 | ], 14 | pwdRequired: false, 15 | }; 16 | 17 | export default migration; 18 | -------------------------------------------------------------------------------- /src/migrations/versions/2.3.3.js: -------------------------------------------------------------------------------- 1 | import { NETWORK_ID, STORAGE } from '../../config/config'; 2 | import { getStorage, setStorage } from '../../api/extension/index'; 3 | import Loader from '../../api/loader'; 4 | 5 | const migration = { 6 | version: '2.3.3', 7 | up: async (pwd) => { 8 | await Loader.load(); 9 | const networks = Object.keys(NETWORK_ID); 10 | const storage = await getStorage(STORAGE.accounts); 11 | const accounts = Object.keys(storage); 12 | 13 | for (let i = 0; i < accounts.length; i++) { 14 | for (let j = 0; j < networks.length; j++) { 15 | if (storage[accounts[i]][networks[j]]) { 16 | const currentAccount = storage[accounts[i]]; 17 | const network = networks[j]; 18 | const currentAccountNetwork = currentAccount[network]; 19 | const paymentKeyHash = Loader.Cardano.Ed25519KeyHash.from_raw_bytes( 20 | Buffer.from(currentAccount.paymentKeyHash, 'hex') 21 | ); 22 | const stakeKeyHash = Loader.Cardano.Ed25519KeyHash.from_raw_bytes( 23 | Buffer.from(currentAccount.stakeKeyHash, 'hex') 24 | ); 25 | const paymentAddr = Loader.Cardano.BaseAddress.new( 26 | network === NETWORK_ID.mainnet 27 | ? Loader.Cardano.NetworkInfo.mainnet().network_id() 28 | : Loader.Cardano.NetworkInfo.testnet().network_id(), 29 | Loader.Cardano.Credential.new_pub_key(paymentKeyHash), 30 | Loader.Cardano.Credential.new_pub_key(stakeKeyHash) 31 | ) 32 | .to_address() 33 | .to_bech32(); 34 | 35 | const rewardAddr = Loader.Cardano.RewardAddress.new( 36 | network === NETWORK_ID.mainnet 37 | ? Loader.Cardano.NetworkInfo.mainnet().network_id() 38 | : Loader.Cardano.NetworkInfo.testnet().network_id(), 39 | Loader.Cardano.Credential.new_pub_key(stakeKeyHash) 40 | ) 41 | .to_address() 42 | .to_bech32(); 43 | 44 | currentAccountNetwork.paymentAddr = paymentAddr; 45 | currentAccountNetwork.rewardAddr = rewardAddr; 46 | } 47 | } 48 | } 49 | 50 | await setStorage({ [STORAGE.accounts]: storage }); 51 | }, 52 | down: async (pwd) => { 53 | const networks = Object.keys(NETWORK_ID); 54 | const storage = await getStorage(STORAGE.accounts); 55 | const accounts = Object.keys(storage); 56 | 57 | for (let i = 0; i < accounts.length; i++) { 58 | for (let j = 0; j < networks.length; j++) { 59 | if (storage[accounts[i]][networks[j]]) { 60 | const currentAccount = storage[accounts[i]]; 61 | const network = networks[j]; 62 | const currentAccountNetwork = currentAccount[network]; 63 | 64 | delete currentAccountNetwork.paymentAddr; 65 | delete currentAccountNetwork.rewardAddr; 66 | } 67 | } 68 | } 69 | 70 | await setStorage({ [STORAGE.accounts]: storage }); 71 | }, 72 | info: [ 73 | { title: 'Improved Coin selection' }, 74 | { title: 'Collateral bug fixing' }, 75 | ], 76 | pwdRequired: false, 77 | }; 78 | 79 | export default migration; 80 | -------------------------------------------------------------------------------- /src/migrations/versions/3.0.0.js: -------------------------------------------------------------------------------- 1 | const migration = { 2 | version: '3.0.0', 3 | up: async () => {}, 4 | down: async () => {}, 5 | info: [ 6 | { 7 | title: '$handle support', 8 | detail: 9 | '$handle address resolution is now supported under the send screen.', 10 | }, 11 | { 12 | title: 'NFT as avatar', 13 | detail: 'You can now use your NFT/collectible as avatar in each account.', 14 | }, 15 | { 16 | title: 'Wallet creation in separate tab', 17 | detail: 18 | 'The wallet creation and importing section is now under a seperate tab.', 19 | }, 20 | ], 21 | pwdRequired: false, 22 | }; 23 | 24 | export default migration; 25 | -------------------------------------------------------------------------------- /src/migrations/versions/3.0.2.js: -------------------------------------------------------------------------------- 1 | const migration = { 2 | version: '3.0.2', 3 | up: async () => {}, 4 | down: async () => {}, 5 | info: [ 6 | { 7 | title: 'Sending screen reworked', 8 | detail: 9 | 'Little UX and layout redesign. There is now a confirmation screen when clicking on send. An optional message can also be appended now to the transaction (CIP-0020).', 10 | }, 11 | { 12 | title: 'Milkomeda support', 13 | detail: 14 | 'Assets can now be moved to the Milkomeda sidechain. This functionality is available under the send screen (only testnet for now).', 15 | }, 16 | { 17 | title: 'ADA as asset', 18 | detail: 'ADA is shown now under assets on the main screen.', 19 | }, 20 | ], 21 | pwdRequired: false, 22 | }; 23 | 24 | export default migration; 25 | -------------------------------------------------------------------------------- /src/migrations/versions/3.3.0.js: -------------------------------------------------------------------------------- 1 | import { NETWORK_ID, STORAGE } from '../../config/config'; 2 | import { getStorage, setStorage } from '../../api/extension/index'; 3 | import Loader from '../../api/loader'; 4 | import { initTx } from '../../api/extension/wallet'; 5 | import { assetsToValue } from '../../api/util'; 6 | 7 | const migration = { 8 | version: '3.3.0', 9 | up: async (pwd) => { 10 | const networkDefault = { 11 | lovelace: null, 12 | minAda: 0, 13 | assets: [], 14 | history: { confirmed: [], details: {} }, 15 | }; 16 | 17 | await Loader.load(); 18 | const storage = await getStorage(STORAGE.accounts); 19 | const accounts = Object.keys(storage); 20 | const networks = Object.keys(NETWORK_ID); 21 | const protocolParameters = await initTx(); 22 | 23 | for (let i = 0; i < accounts.length; i++) { 24 | const currentAccount = storage[accounts[i]]; 25 | const paymentKeyHash = Loader.Cardano.Ed25519KeyHash.from_raw_bytes( 26 | Buffer.from(currentAccount.paymentKeyHash, 'hex') 27 | ); 28 | const paymentKeyHashBech32 = paymentKeyHash.to_bech32('addr_vkh'); 29 | currentAccount.paymentKeyHashBech32 = paymentKeyHashBech32; 30 | 31 | currentAccount[NETWORK_ID.preview] = { 32 | ...networkDefault, 33 | paymentAddr: currentAccount[NETWORK_ID.testnet].paymentAddr, 34 | rewardAddr: currentAccount[NETWORK_ID.testnet].rewardAddr, 35 | }; 36 | 37 | currentAccount[NETWORK_ID.preprod] = { 38 | ...networkDefault, 39 | paymentAddr: currentAccount[NETWORK_ID.testnet].paymentAddr, 40 | rewardAddr: currentAccount[NETWORK_ID.testnet].rewardAddr, 41 | }; 42 | } 43 | 44 | // add minAda 45 | for (let i = 0; i < accounts.length; i++) { 46 | for (let j = 0; j < networks.length; j++) { 47 | if (storage[accounts[i]][networks[j]]) { 48 | const currentAccountNetwork = storage[accounts[i]][networks[j]]; 49 | let assets = currentAccountNetwork.assets; 50 | if (assets.length > 0) { 51 | const amount = await assetsToValue(assets); 52 | const checkOutput = Loader.Cardano.TransactionOutput.new( 53 | Loader.Cardano.Address.from_bech32( 54 | currentAccountNetwork.paymentAddr 55 | ), 56 | amount 57 | ); 58 | currentAccountNetwork.minAda = Loader.Cardano.min_ada_required( 59 | checkOutput, 60 | BigInt( 61 | // protocolParameters.coinsPerUtxoWord 62 | (4310).toString() // We hardcode this, since we don't know if Blockfrost switches PP quickly during the epoch transition 63 | ) 64 | ).toString(); 65 | } else { 66 | currentAccountNetwork.minAda = 0; 67 | } 68 | } 69 | } 70 | } 71 | 72 | await setStorage({ [STORAGE.accounts]: storage }); 73 | }, 74 | down: async (pwd) => { 75 | const storage = await getStorage(STORAGE.accounts); 76 | const accounts = Object.keys(storage); 77 | 78 | for (let i = 0; i < accounts.length; i++) { 79 | const currentAccount = storage[accounts[i]]; 80 | delete currentAccount.paymentKeyHashBech32; 81 | 82 | delete currentAccount[NETWORK_ID.preview]; 83 | delete currentAccount[NETWORK_ID.preprod]; 84 | } 85 | 86 | await setStorage({ [STORAGE.accounts]: storage }); 87 | }, 88 | info: [ 89 | { title: 'Support: Vasil era' }, 90 | { 91 | title: 'Support: Mangled addresses', 92 | detail: 93 | 'Nami keeps track now of all addresses with the same payment credential.', 94 | }, 95 | { title: 'Support: Preview and Preprod network' }, 96 | { title: 'Bug fixing' }, 97 | ], 98 | pwdRequired: false, 99 | }; 100 | 101 | export default migration; 102 | -------------------------------------------------------------------------------- /src/pages/Content/index.js: -------------------------------------------------------------------------------- 1 | import { Messaging } from '../../api/messaging'; 2 | 3 | const injectScript = () => { 4 | const script = document.createElement('script'); 5 | script.async = false; 6 | script.src = chrome.runtime.getURL('injected.bundle.js'); 7 | script.onload = function () { 8 | this.remove(); 9 | }; 10 | (document.head || document.documentElement).appendChild(script); 11 | }; 12 | 13 | async function shouldInject() { 14 | // do not inject since the migration is not dismissible anymore 15 | return false 16 | } 17 | 18 | if (await shouldInject()) { 19 | injectScript(); 20 | Messaging.createProxyController(); 21 | } 22 | -------------------------------------------------------------------------------- /src/pages/Content/injected.js: -------------------------------------------------------------------------------- 1 | import { 2 | enable, 3 | getAddress, 4 | getBalance, 5 | getCollateral, 6 | getNetworkId, 7 | getRewardAddress, 8 | getUtxos, 9 | isEnabled, 10 | off, 11 | on, 12 | signData, 13 | signDataCIP30, 14 | signTx, 15 | submitTx, 16 | } from '../../api/webpage'; 17 | import { EVENT } from '../../config/config'; 18 | 19 | //dApp connector API follows https://github.com/cardano-foundation/CIPs/pull/88 20 | 21 | const logDeprecated = () => { 22 | console.warn( 23 | 'This Nami API implementation is deprecated soon. Please follow the API under the window.cardano.nami namespace. For more information check out CIP-30: https://github.com/cardano-foundation/CIPs/tree/master/CIP-0030' 24 | ); 25 | return true; 26 | }; 27 | 28 | //Initial version (deprecated soon) 29 | window.cardano = { 30 | ...(window.cardano || {}), 31 | enable: () => logDeprecated() && enable(), 32 | isEnabled: () => logDeprecated() && isEnabled(), 33 | getBalance: () => logDeprecated() && getBalance(), 34 | signData: (address, payload) => logDeprecated() && signData(address, payload), 35 | signTx: (tx, partialSign) => logDeprecated() && signTx(tx, partialSign), 36 | submitTx: (tx) => logDeprecated() && submitTx(tx), 37 | getUtxos: (amount, paginate) => logDeprecated() && getUtxos(amount, paginate), 38 | getCollateral: () => logDeprecated() && getCollateral(), 39 | getUsedAddresses: async () => logDeprecated() && [await getAddress()], 40 | getUnusedAddresses: async () => logDeprecated() && [], 41 | getChangeAddress: () => logDeprecated() && getAddress(), 42 | getRewardAddress: () => logDeprecated() && getRewardAddress(), 43 | getNetworkId: () => logDeprecated() && getNetworkId(), 44 | onAccountChange: (callback) => 45 | logDeprecated() && on(EVENT.accountChange, callback), 46 | onNetworkChange: (callback) => 47 | logDeprecated() && on(EVENT.networkChange, callback), 48 | off, 49 | _events: {}, 50 | }; 51 | 52 | // // CIP-30 53 | 54 | window.cardano = { 55 | ...(window.cardano || {}), 56 | nami: { 57 | enable: async () => { 58 | if (await enable()) { 59 | return { 60 | getBalance: () => getBalance(), 61 | signData: (address, payload) => signDataCIP30(address, payload), 62 | signTx: (tx, partialSign) => signTx(tx, partialSign), 63 | submitTx: (tx) => submitTx(tx), 64 | getUtxos: (amount, paginate) => getUtxos(amount, paginate), 65 | getUsedAddresses: async () => [await getAddress()], 66 | getUnusedAddresses: async () => [], 67 | getChangeAddress: () => getAddress(), 68 | getRewardAddresses: async () => [await getRewardAddress()], 69 | getNetworkId: () => getNetworkId(), 70 | experimental: { 71 | on: (eventName, callback) => on(eventName, callback), 72 | off: (eventName, callback) => off(eventName, callback), 73 | getCollateral: () => getCollateral(), 74 | }, 75 | }; 76 | } 77 | }, 78 | isEnabled: () => isEnabled(), 79 | apiVersion: '0.1.0', 80 | name: 'Nami', 81 | icon: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 486.17 499.86'%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill:%23349ea3;%7D%3C/style%3E%3C/defs%3E%3Cg id='Layer_2' data-name='Layer 2'%3E%3Cg id='Layer_1-2' data-name='Layer 1'%3E%3Cpath id='path16' class='cls-1' d='M73.87,52.15,62.11,40.07A23.93,23.93,0,0,1,41.9,61.87L54,73.09,486.17,476ZM102.4,168.93V409.47a23.76,23.76,0,0,1,32.13-2.14V245.94L395,499.86h44.87Zm303.36-55.58a23.84,23.84,0,0,1-16.64-6.68v162.8L133.46,15.57H84L421.28,345.79V107.6A23.72,23.72,0,0,1,405.76,113.35Z'/%3E%3Cpath id='path18' class='cls-1' d='M38.27,0A38.25,38.25,0,1,0,76.49,38.27v0A38.28,38.28,0,0,0,38.27,0ZM41.9,61.8a22,22,0,0,1-3.63.28A23.94,23.94,0,1,1,62.18,38.13V40A23.94,23.94,0,0,1,41.9,61.8Z'/%3E%3Cpath id='path20' class='cls-1' d='M405.76,51.2a38.24,38.24,0,0,0,0,76.46,37.57,37.57,0,0,0,15.52-3.3A38.22,38.22,0,0,0,405.76,51.2Zm15.52,56.4a23.91,23.91,0,1,1,8.39-18.18A23.91,23.91,0,0,1,421.28,107.6Z'/%3E%3Cpath id='path22' class='cls-1' d='M134.58,390.81A38.25,38.25,0,1,0,157.92,426a38.24,38.24,0,0,0-23.34-35.22Zm-15,59.13A23.91,23.91,0,1,1,143.54,426a23.9,23.9,0,0,1-23.94,23.91Z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E", 82 | _events: {}, 83 | }, 84 | }; 85 | -------------------------------------------------------------------------------- /src/pages/Content/trezorContentScript.js: -------------------------------------------------------------------------------- 1 | // Communicate from background script to popup 2 | let port = chrome.runtime.connect({ name: 'trezor-connect' }); 3 | port.onMessage.addListener((message) => { 4 | window.postMessage(message, window.location.origin); 5 | }); 6 | port.onDisconnect.addListener(() => { 7 | // eslint-disable-next-line unicorn/no-null 8 | port = null; 9 | }); 10 | 11 | // communicate from popup to background script 12 | window.addEventListener('message', (event) => { 13 | if (port && event.source === window && event.data) { 14 | port.postMessage({ data: event.data }); 15 | } 16 | }); 17 | -------------------------------------------------------------------------------- /src/pages/Popup/internalPopup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Nami 6 | 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /src/pages/Popup/mainPopup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Nami 6 | 12 | 17 | 18 | 19 | 20 |
21 | 22 | 23 | -------------------------------------------------------------------------------- /src/pages/Tab/createWalletTab.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Nami - Create Wallet 6 | 7 | 8 |
9 | 10 | 11 | -------------------------------------------------------------------------------- /src/pages/Tab/hwTab.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Nami - Connect HW 6 | 7 | 8 |
9 | 10 | 11 | -------------------------------------------------------------------------------- /src/pages/Tab/trezorTx.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Nami - Sign Trezor transaction 6 | 7 | 8 |
9 | 10 | 11 | -------------------------------------------------------------------------------- /src/test/unit/api/extension/index.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | getStorage, 3 | encryptWithPassword, 4 | decryptWithPassword, 5 | createWallet, 6 | switchAccount, 7 | createAccount, 8 | setWhitelisted, 9 | getWhitelisted, 10 | getNetwork, 11 | setNetwork, 12 | getCurrentAccount, 13 | } from '../../../../api/extension'; 14 | import Loader from '../../../../api/loader'; 15 | import { ERROR, NODE, STORAGE } from '../../../../config/config'; 16 | 17 | beforeAll(async () => { 18 | const seed = 19 | 'midnight draft salt dirt woman tragic cause immense dad later jaguar finger nerve nerve sign job erase citizen cube neglect token bracket orient narrow'; 20 | const name = 'Wallet 1'; 21 | const password = 'password123'; 22 | await Loader.load(); 23 | await createWallet(name, seed, password); 24 | }); 25 | 26 | test('storage initialized correctly', async () => { 27 | const store = await getStorage(); 28 | expect(store).toHaveProperty(STORAGE.accounts); 29 | expect(store).toHaveProperty(STORAGE.currency); 30 | expect(store).toHaveProperty(STORAGE.encryptedKey); 31 | expect(store).toHaveProperty(STORAGE.currentAccount); 32 | expect(store).toHaveProperty(STORAGE.network); 33 | expect(Object.keys(store).length).toBe(5); 34 | }); 35 | 36 | test('should have whitelist', async () => { 37 | await setWhitelisted('https://namiwallet.io'); 38 | const store = await getStorage(); 39 | expect(store).toHaveProperty(STORAGE.whitelisted); 40 | const whitelisted = await getWhitelisted(); 41 | expect(whitelisted).toEqual(['https://namiwallet.io']); 42 | expect(Object.keys(store).length).toBe(6); 43 | }); 44 | 45 | test('account structure is correct', async () => { 46 | const store = await getStorage(); 47 | const account = store.accounts[store.currentAccount]; 48 | expect(account).toHaveProperty('avatar'); 49 | expect(account).toHaveProperty('name'); 50 | expect(account).toHaveProperty('index'); 51 | expect(account).toHaveProperty('paymentKeyHash'); 52 | expect(account).toHaveProperty('stakeKeyHash'); 53 | expect(account).toHaveProperty('mainnet'); 54 | expect(account).toHaveProperty('testnet'); 55 | expect(account.mainnet).toHaveProperty('lovelace'); 56 | expect(account.mainnet).toHaveProperty('assets'); 57 | expect(account.mainnet).toHaveProperty('history'); 58 | expect(account.mainnet.history).toHaveProperty('confirmed'); 59 | expect(account.mainnet.history).toHaveProperty('details'); 60 | }); 61 | 62 | test('current account should be 0', async () => { 63 | const currentAccount = await getStorage('currentAccount'); 64 | expect(currentAccount).toBe(0); 65 | }); 66 | 67 | test('current account should be 1', async () => { 68 | const name = 'Wallet 2'; 69 | const password = 'password123'; 70 | await createAccount(name, password); 71 | await switchAccount(1); 72 | const currentAccount = await getStorage('currentAccount'); 73 | expect(currentAccount).toBe(1); 74 | }); 75 | 76 | test('expect error because of wrong password', async () => { 77 | const name = 'Wallet 3'; 78 | const password = 'password456'; 79 | expect.assertions(1); 80 | try { 81 | const index = await createAccount(name, password); 82 | await switchAccount(index); 83 | } catch (e) { 84 | expect(e).toBe(ERROR.wrongPassword); 85 | } 86 | }); 87 | 88 | test('expect mainnet', async () => { 89 | const network = await getNetwork(); 90 | expect(network.id).toBe('mainnet'); 91 | }); 92 | 93 | test('expect testnet address', async () => { 94 | await setNetwork({ id: 'testnet', node: NODE.testnet }); 95 | const account = await getCurrentAccount(); 96 | expect(account.paymentAddr).toContain('addr_'); 97 | }); 98 | 99 | test('should encrypt/decrypt root key correctly', async () => { 100 | const rootKey = Loader.Cardano.Bip32PrivateKey.generate_ed25519_bip32(); 101 | const password = 'test123'; 102 | const rootKeyBytes = rootKey.to_raw_key().to_raw_bytes(); 103 | const encryptedKey = await encryptWithPassword(password, rootKeyBytes); 104 | expect(Buffer.from(rootKeyBytes, 'hex').toString('hex')).not.toBe( 105 | encryptedKey 106 | ); 107 | const decryptedKey = await decryptWithPassword(password, encryptedKey); 108 | expect(Buffer.from(rootKeyBytes, 'hex').toString('hex')).toBe(decryptedKey); 109 | }); 110 | -------------------------------------------------------------------------------- /src/test/unit/api/util.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | assetsToValue, 3 | convertMetadataPropToString, 4 | linkToSrc, 5 | valueToAssets, 6 | } from '../../../api/util'; 7 | import Loader from '../../../api/loader'; 8 | import provider from '../../../config/provider'; 9 | 10 | beforeAll(() => { 11 | Loader.load(); 12 | }); 13 | 14 | test('expect correct assets to value conversion', async () => { 15 | const assetId = '2a286ad895d091f2b3d168a6091ad2627d30a72761a5bc36eef0074074657374313233'; 16 | const policyId = assetId.slice(0, 56); 17 | const assetName = assetId.slice(56); 18 | const assets = [ 19 | { unit: 'lovelace', quantity: '1000000' }, 20 | { 21 | unit: assetId, 22 | quantity: '10', 23 | }, 24 | ]; 25 | const value = await assetsToValue(assets); 26 | 27 | expect(value.coin()).toBe(1000000n); 28 | const multiAsset = value.multi_asset(); 29 | expect(multiAsset.keys().len()).toBe(1); 30 | const scriptHash = multiAsset.keys().get(0); 31 | expect(scriptHash.to_hex()).toBe(policyId); 32 | const assetsMap = multiAsset.get_assets(scriptHash); 33 | expect(assetsMap.len()).toBe(1); 34 | const cmlAssetName = assetsMap.keys().get(0); 35 | expect(cmlAssetName.to_hex()).toBe(assetName); 36 | expect(assetsMap.get(cmlAssetName)).toBe(10n) 37 | }); 38 | 39 | test('expect correct value to assets conversion', async () => { 40 | const multiAsset = Loader.Cardano.MultiAsset.new(); 41 | const value = Loader.Cardano.Value.new( 42 | BigInt('1000000'), multiAsset 43 | ); 44 | const assetsSet = Loader.Cardano.MapAssetNameToCoin.new(); 45 | assetsSet.insert( 46 | Loader.Cardano.AssetName.from_raw_bytes(Buffer.from('74657374313233', 'hex')), 47 | BigInt('10') 48 | ); 49 | multiAsset.insert_assets( 50 | Loader.Cardano.ScriptHash.from_raw_bytes( 51 | Buffer.from( 52 | '2a286ad895d091f2b3d168a6091ad2627d30a72761a5bc36eef00740', 53 | 'hex' 54 | ) 55 | ), 56 | assetsSet 57 | ); 58 | const assets = await valueToAssets(value); 59 | const testAssets = [ 60 | { unit: 'lovelace', quantity: '1000000' }, 61 | { 62 | unit: '2a286ad895d091f2b3d168a6091ad2627d30a72761a5bc36eef0074074657374313233', 63 | quantity: '10', 64 | }, 65 | ]; 66 | assets.forEach((asset, i) => { 67 | const testAsset = testAssets[i]; 68 | expect(asset.unit).toEqual(testAsset.unit); 69 | expect(asset.quantity).toEqual(testAsset.quantity); 70 | }); 71 | }); 72 | 73 | describe('test linkToSrc', () => { 74 | test('expect right source from ipfs link', () => { 75 | const testLink = 'ipfs://QmVSameQt9i37hdrLwMSfoAg1aVKrjtBtuDHeQTgyVhUXC'; 76 | const testLink1 = 77 | 'ipfs://ipfs/QmVSameQt9i37hdrLwMSfoAg1aVKrjtBtuDHeQTgyVhUXC'; 78 | const httpsLink = linkToSrc(testLink); 79 | const httpsLink1 = linkToSrc(testLink1); 80 | expect(httpsLink).toEqual( 81 | provider.api.ipfs + '/' + 'QmVSameQt9i37hdrLwMSfoAg1aVKrjtBtuDHeQTgyVhUXC' 82 | ); 83 | expect(httpsLink1).toEqual( 84 | provider.api.ipfs + '/' + 'QmVSameQt9i37hdrLwMSfoAg1aVKrjtBtuDHeQTgyVhUXC' 85 | ); 86 | }); 87 | test('expect right source from https', () => { 88 | const testLink = 89 | 'https://ipfs.io/ipfs/QmVSameQt9i37hdrLwMSfoAg1aVKrjtBtuDHeQTgyVhUXC'; 90 | const httpsLink = linkToSrc(testLink); 91 | expect(httpsLink).toEqual(testLink); 92 | }); 93 | test('expect no source', () => { 94 | const testLink = 'invalid src'; 95 | const httpsLink = linkToSrc(testLink); 96 | expect(httpsLink).toBe(null); 97 | }); 98 | test('expect data uri for png', () => { 99 | const testLink = 'YWJj'; 100 | const httpsLink = linkToSrc(testLink, true); 101 | expect(httpsLink).toBe('data:image/png;base64,YWJj'); 102 | }); 103 | test('expect data uri', () => { 104 | const link = 105 | 'data:image/png;base64,iVB\ 106 | ORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAABlBMVEU\ 107 | AAAD///+l2Z/dAAAAM0lEQVR4nGP4/5/h/1+G/58ZDrAz3D/McH8\ 108 | yw83NDDeNGe4Ug9C9zwz3gVLMDA/A6P9/AFGGFyjOXZtQAAAAAEl\ 109 | FTkSuQmCC'; 110 | const testLink = linkToSrc(link); 111 | expect(testLink).toEqual(link); 112 | }); 113 | test('expect correct array to string conversion', () => { 114 | const normalString = 'metadatateststring'; 115 | const array = ['meta', 'data', 'teststring']; 116 | 117 | const convertedString = convertMetadataPropToString(normalString); 118 | const convertedArray = convertMetadataPropToString(array); 119 | expect(convertedArray).toEqual(normalString); 120 | expect(convertedArray).toEqual(convertedString); 121 | }); 122 | }); 123 | -------------------------------------------------------------------------------- /src/test/unit/api/webpage/eventRegistration.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | import { on, off } from '../../../../api/webpage/eventRegistration'; 6 | import { TARGET } from '../../../../config/config'; 7 | 8 | describe('webpage/eventRegistring', () => { 9 | const makeEvent = (eventType, detail) => 10 | new CustomEvent(`${TARGET}${eventType}`, { detail }); 11 | 12 | describe('on', () => { 13 | beforeEach(() => { 14 | window.cardano = { 15 | nami: { 16 | _events: {}, 17 | }, 18 | }; 19 | }); 20 | 21 | test('invokes the callback when a the target event is triggered', () => { 22 | const eventType = 'mock-event'; 23 | const mockPayload = 'mock-payload'; 24 | const event = makeEvent(eventType, mockPayload); 25 | const mockFn = jest.fn(); 26 | 27 | on(eventType, mockFn); 28 | 29 | window.dispatchEvent(event); 30 | 31 | expect(mockFn).toHaveBeenCalledTimes(1); 32 | expect(mockFn).toHaveBeenCalledWith(mockPayload); 33 | }); 34 | 35 | test('does not invoke the callback when a different event is triggered', () => { 36 | const mockFn = jest.fn(); 37 | 38 | on('event-A', mockFn); 39 | 40 | const mockPayload = 'mock-payload'; 41 | const event = makeEvent('event-B', mockPayload); 42 | 43 | window.dispatchEvent(event); 44 | 45 | expect(mockFn).not.toHaveBeenCalled(); 46 | }); 47 | }); 48 | 49 | describe('of', () => { 50 | const mockEventType = 'mock-event'; 51 | const mockCallback = jest.fn(); 52 | const mockHandler = jest.fn().mockImplementation(() => mockCallback()); 53 | 54 | beforeEach(() => { 55 | jest.resetAllMocks(); 56 | 57 | window.cardano = { 58 | nami: { 59 | _events: { 60 | [mockEventType]: [[mockCallback, mockHandler]], 61 | }, 62 | }, 63 | }; 64 | }); 65 | 66 | test('clean out matching callbacks fom the given event', () => { 67 | off(mockEventType, mockCallback); 68 | 69 | expect(window.cardano.nami._events).toEqual({ 70 | [mockEventType]: [], 71 | }); 72 | }); 73 | 74 | test('stops the given callback from being invoked when cleaned out', () => { 75 | off(mockEventType, mockCallback); 76 | 77 | const event = makeEvent(mockEventType); 78 | 79 | window.dispatchEvent(event); 80 | 81 | expect(mockCallback).not.toHaveBeenCalled(); 82 | }); 83 | 84 | test('does not stop other callbacks from being invoked after cleaned one out', () => { 85 | const mockFn = jest.fn(); 86 | 87 | on('event-A', mockFn); 88 | 89 | off('mockEventType', mockCallback); 90 | 91 | const event = makeEvent('event-A'); 92 | 93 | window.dispatchEvent(event); 94 | 95 | expect(mockFn).toHaveBeenCalledTimes(1); 96 | }); 97 | }); 98 | }); 99 | -------------------------------------------------------------------------------- /src/test/unit/migrations/migration.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | getStorage, 3 | setStorage, 4 | createWallet, 5 | createAccount, 6 | } from '../../../api/extension'; 7 | import Loader from '../../../api/loader'; 8 | import { STORAGE } from '../../../config/config'; 9 | 10 | const harden = (num) => { 11 | return 0x80000000 + num; 12 | }; 13 | 14 | beforeAll(async () => { 15 | const seed = 16 | 'midnight draft salt dirt woman tragic cause immense dad later jaguar finger nerve nerve sign job erase citizen cube neglect token bracket orient narrow'; 17 | const name = 'Wallet 1'; 18 | const password = 'password123'; 19 | await Loader.load(); 20 | await createWallet(name, seed, password); 21 | await createAccount('Wallet 2', password); 22 | await createAccount('Wallet 3', password); 23 | let accounts = await getStorage(STORAGE.accounts); 24 | for (const accountIndex in accounts) { 25 | const account = accounts[accountIndex]; 26 | account.mainnet = { 27 | ...account.mainnet, 28 | lovelace: Math.floor(Math.random() * 10000000).toString(), 29 | assets: [ 30 | { 31 | unit: '0996b4e1e1fef04e29d9ea3c4282e011ddb1263513c7bd2ddac2fff9012', 32 | quantity: Math.floor(Math.random() * 1000).toString(), 33 | }, 34 | ], 35 | }; 36 | //test account function from 1.1.4 without minAda 37 | delete account.mainnet.minAda; 38 | delete account.testnet.minAda; 39 | } 40 | await setStorage({ [STORAGE.accounts]: accounts }); 41 | }); 42 | 43 | describe('no migration', () => { 44 | test('no migration', () => {}); 45 | }); 46 | -------------------------------------------------------------------------------- /src/ui/app.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Routes, Route, useLocation } from 'react-router-dom'; 3 | import { useNavigate } from 'react-router-dom'; 4 | import { AnalyticsConsentModal } from '../features/analytics/ui/AnalyticsConsentModal'; 5 | import { Box, Spinner } from '@chakra-ui/react'; 6 | import Welcome from './app/pages/welcome'; 7 | import Wallet from './app/pages/wallet'; 8 | import { getAccounts } from '../api/extension'; 9 | import Settings from './app/pages/settings'; 10 | import Send from './app/pages/send'; 11 | import { useStoreActions, useStoreState } from 'easy-peasy'; 12 | import { useAnalyticsContext } from '../features/analytics/provider'; 13 | import { TermsAndPrivacyProvider } from '../features/terms-and-privacy'; 14 | 15 | export const App = () => { 16 | const route = useStoreState((state) => state.globalModel.routeStore.route); 17 | const setRoute = useStoreActions( 18 | (actions) => actions.globalModel.routeStore.setRoute 19 | ); 20 | const navigate = useNavigate(); 21 | const location = useLocation(); 22 | const [isLoading, setIsLoading] = React.useState(true); 23 | const [analytics, setAnalyticsConsent] = useAnalyticsContext(); 24 | const init = async () => { 25 | const hasWallet = await getAccounts(); 26 | if (hasWallet) { 27 | navigate('/wallet'); 28 | // Set route from localStorage if available 29 | if (route && route !== '/wallet') { 30 | route 31 | .slice(1) 32 | .split('/') 33 | .reduce((acc, r) => { 34 | const fullRoute = acc + `/${r}`; 35 | navigate(fullRoute); 36 | return fullRoute; 37 | }, ''); 38 | } 39 | } else { 40 | navigate('/welcome'); 41 | } 42 | setIsLoading(false); 43 | }; 44 | 45 | React.useEffect(() => { 46 | init(); 47 | }, []); 48 | 49 | React.useEffect(() => { 50 | if (!isLoading) { 51 | setRoute(location.pathname); 52 | } 53 | }, [location, isLoading, setRoute]); 54 | 55 | return isLoading ? ( 56 | 63 | 64 | 65 | ) : ( 66 |
67 | 68 | 72 | 73 | 74 | } 75 | /> 76 | } /> 77 | } /> 78 | } /> 79 | 80 | 84 |
85 | ); 86 | }; 87 | -------------------------------------------------------------------------------- /src/ui/app/components/UpgradeModal.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { IoRocketSharp } from 'react-icons/io5'; 3 | import { 4 | Icon, 5 | Box, 6 | Text, 7 | Button, 8 | Modal, 9 | ModalOverlay, 10 | ModalContent, 11 | ModalHeader, 12 | ModalBody, 13 | ModalCloseButton, 14 | UnorderedList, 15 | ListItem, 16 | Heading, 17 | useDisclosure 18 | } from '@chakra-ui/react'; 19 | 20 | export const UpgradeModal = React.forwardRef((props, ref) => { 21 | const { isOpen, onOpen, onClose } = useDisclosure(); 22 | 23 | React.useImperativeHandle(ref, () => ({ 24 | openModal() { 25 | onOpen(); 26 | }, 27 | })); 28 | 29 | return ( 30 | 38 | 39 | 40 | 41 | 42 | What's new in Nami ? 43 | 44 | 45 | 46 | {props.info.map((item) => ( 47 | 48 | 55 | Version {item.version} 56 | 57 | 58 | {item.info.map((info, index) => ( 59 | 60 | {info.title} 61 | {info.detail} 62 | 63 | ))} 64 | 65 | 66 | ))} 67 | 68 | 69 | 72 | 73 | 74 | 75 | 76 | ); 77 | }); 78 | -------------------------------------------------------------------------------- /src/ui/app/components/about.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Modal, 4 | ModalOverlay, 5 | ModalContent, 6 | ModalHeader, 7 | ModalBody, 8 | ModalCloseButton, 9 | useDisclosure, 10 | useColorModeValue, 11 | Image, 12 | Text, 13 | Box, 14 | Link, 15 | } from '@chakra-ui/react'; 16 | 17 | import LogoWhite from '../../../assets/img/logoWhite.svg'; 18 | import LogoBlack from '../../../assets/img/logo.svg'; 19 | import IOHKWhite from '../../../assets/img/iohkWhite.svg'; 20 | import IOHKBlack from '../../../assets/img/iohk.svg'; 21 | import TermsOfUse from './termsOfUse'; 22 | import PrivacyPolicy from './privacyPolicy'; 23 | import { useCaptureEvent } from '../../../features/analytics/hooks'; 24 | import { Events } from '../../../features/analytics/events'; 25 | 26 | const { version } = require('../../../../package.json'); 27 | 28 | const About = React.forwardRef((props, ref) => { 29 | const capture = useCaptureEvent(); 30 | const { isOpen, onOpen, onClose } = useDisclosure(); 31 | const Logo = useColorModeValue(LogoBlack, LogoWhite); 32 | const IOHK = useColorModeValue(IOHKWhite, IOHKBlack); 33 | 34 | const termsRef = React.useRef(); 35 | const privacyPolRef = React.useRef(); 36 | 37 | React.useImperativeHandle(ref, () => ({ 38 | openModal() { 39 | onOpen(); 40 | }, 41 | closeModal() { 42 | onClose(); 43 | }, 44 | })); 45 | return ( 46 | <> 47 | 54 | 55 | 56 | About 57 | 58 | 64 | window.open('https://namiwallet.io')} 67 | width="90px" 68 | src={Logo} 69 | /> 70 | 71 | {version} 72 | 73 | 79 | 80 | Maintained by{' '} 81 | window.open('https://iohk.io/')} 83 | style={{ textDecoration: 'underline', cursor: 'pointer' }} 84 | > 85 | IOG 86 | 87 | 88 | 89 | window.open('https://iohk.io/')} 92 | src={IOHK} 93 | width="66px" 94 | /> 95 | 96 | 97 | {/* Footer */} 98 | 99 | { 101 | capture(Events.SettingsTermsAndConditionsClick); 102 | termsRef.current.openModal(); 103 | }} 104 | color="GrayText" 105 | > 106 | Terms of use 107 | 108 | | 109 | privacyPolRef.current.openModal()} 111 | color="GrayText" 112 | > 113 | Privacy Policy 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | ); 124 | }); 125 | 126 | export default About; 127 | -------------------------------------------------------------------------------- /src/ui/app/components/account.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { getCurrentAccount } from '../../../api/extension'; 3 | 4 | import Logo from '../../../assets/img/logoWhite.svg'; 5 | import { Box, Text, Image, useColorModeValue } from '@chakra-ui/react'; 6 | import AvatarLoader from './avatarLoader'; 7 | 8 | const Account = React.forwardRef((props, ref) => { 9 | const avatarBg = useColorModeValue('white', 'gray.700'); 10 | const panelBg = useColorModeValue('#349EA3', 'gray.800'); 11 | const [account, setAccount] = React.useState(null); 12 | 13 | const initAccount = () => 14 | getCurrentAccount().then((account) => setAccount(account)); 15 | 16 | React.useImperativeHandle(ref, () => ({ 17 | updateAccount() { 18 | initAccount(); 19 | }, 20 | })); 21 | 22 | React.useEffect(() => { 23 | initAccount(); 24 | }, []); 25 | 26 | return ( 27 | 35 | 46 | 47 | 48 | 61 | 66 | 67 | 76 | 77 | {account && account.name} 78 | 79 | 80 | 81 | ); 82 | }); 83 | 84 | export default Account; 85 | -------------------------------------------------------------------------------- /src/ui/app/components/assetPopover.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | Popover, 3 | PopoverArrow, 4 | PopoverBody, 5 | PopoverCloseButton, 6 | PopoverContent, 7 | PopoverTrigger, 8 | Box, 9 | Portal, 10 | Image, 11 | Avatar, 12 | Text, 13 | } from '@chakra-ui/react'; 14 | import React from 'react'; 15 | import Copy from './copy'; 16 | import UnitDisplay from './unitDisplay'; 17 | 18 | const AssetPopover = ({ asset, gutter, ...props }) => { 19 | return ( 20 | 21 | 22 | {props.children} 23 | 24 | 25 | 26 | 27 | 28 | 37 | {asset && ( 38 | 47 | } 53 | /> 54 | 55 | 59 | 67 | {asset.displayName || asset.name} 68 | 69 | 70 | 71 | 72 | 76 | 77 | 78 | 79 | 80 | 81 | Policy: {asset.policy} 82 | 83 | 84 | 85 | 86 | 91 | 92 | 93 | Asset: {asset.fingerprint} 94 | 95 | 96 | 97 | 98 | )} 99 | 100 | 101 | 102 | 103 | ); 104 | }; 105 | 106 | export default AssetPopover; 107 | -------------------------------------------------------------------------------- /src/ui/app/components/assetsModal.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Box, 4 | Button, 5 | Modal, 6 | ModalBody, 7 | ModalContent, 8 | useColorModeValue, 9 | useDisclosure, 10 | } from '@chakra-ui/react'; 11 | import { Scrollbars } from './scrollbar'; 12 | import { LazyLoadComponent } from 'react-lazy-load-image-component'; 13 | import Asset from './asset'; 14 | 15 | const AssetsModal = React.forwardRef((props, ref) => { 16 | const { isOpen, onOpen, onClose } = useDisclosure(); 17 | const [data, setData] = React.useState({ 18 | title: '', 19 | assets: [], 20 | background: '', 21 | color: 'inherit', 22 | }); 23 | const background = useColorModeValue('white', 'gray.800'); 24 | 25 | const abs = (big) => { 26 | return big < 0 ? BigInt(big) * BigInt(-1) : big; 27 | }; 28 | 29 | React.useImperativeHandle(ref, () => ({ 30 | openModal(data) { 31 | setData(data); 32 | onOpen(); 33 | }, 34 | })); 35 | 36 | return ( 37 | 43 | 49 | 50 | 51 | 58 | 59 | 65 | {data.title} 66 | 67 | 68 | {data.assets.map((asset, index) => { 69 | asset = { 70 | ...asset, 71 | quantity: abs(asset.quantity).toString(), 72 | }; 73 | return ( 74 | 75 | 76 | 83 | 88 | 89 | 90 | 91 | ); 92 | })} 93 | 101 | 109 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | ); 120 | }); 121 | 122 | export default AssetsModal; 123 | -------------------------------------------------------------------------------- /src/ui/app/components/autocomplete.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef } from 'react'; 2 | import { 3 | Box, 4 | Flex, 5 | IconButton, 6 | Input, 7 | InputGroup, 8 | InputRightElement, 9 | List, 10 | ListItem, 11 | } from '@chakra-ui/react'; 12 | import { matchSorter } from 'match-sorter'; 13 | import { 14 | CheckCircleIcon, 15 | ChevronDownIcon, 16 | SmallAddIcon, 17 | } from '@chakra-ui/icons'; 18 | 19 | const Autocomplete = ({ 20 | options, 21 | result, 22 | setResult, 23 | placeholder, 24 | renderBadge, 25 | inputName, 26 | inputId, 27 | bgHoverColor, 28 | createText, 29 | allowCreation, 30 | notFoundText, 31 | isInvalid, 32 | width, 33 | ...props 34 | }) => { 35 | const [optionsCopy, setOptionsCopy] = useState(options); 36 | const [partialResult, setPartialResult] = useState(); 37 | const [displayOptions, setDisplayOptions] = useState(false); 38 | const [inputValue, setInputValue] = useState(); 39 | const inputRef = useRef(null); 40 | 41 | const filterOptions = (inputValue) => { 42 | if (inputValue) { 43 | setDisplayOptions(true); 44 | setPartialResult( 45 | matchSorter(optionsCopy, inputValue, { keys: ['label', 'value'] }) 46 | ); 47 | setInputValue(inputValue); 48 | } else { 49 | setDisplayOptions(false); 50 | } 51 | }; 52 | 53 | const selectOption = (option) => { 54 | if (result.includes(option)) { 55 | setResult([ 56 | ...result.filter( 57 | (existingOption) => existingOption.value !== option.value 58 | ), 59 | ]); 60 | } else { 61 | setResult([option, ...result]); 62 | } 63 | }; 64 | 65 | const isOptionSelected = (option) => { 66 | return ( 67 | result.filter((selectedOption) => selectedOption.value === option.value) 68 | .length > 0 69 | ); 70 | }; 71 | 72 | const createOption = () => { 73 | if (inputValue && allowCreation) { 74 | const newOption = { 75 | label: inputValue, 76 | value: inputValue, 77 | }; 78 | setOptionsCopy([newOption, ...optionsCopy]); 79 | selectOption(newOption); 80 | setDisplayOptions(false); 81 | if (inputRef && inputRef.current !== null) { 82 | inputRef.current.value = ''; 83 | } 84 | } 85 | }; 86 | 87 | const selectOptionFromList = (option) => { 88 | selectOption(option); 89 | setDisplayOptions(false); 90 | if (inputRef && inputRef.current !== null) { 91 | inputRef.current.value = ''; 92 | } 93 | }; 94 | 95 | const renderCheckIcon = (option) => { 96 | if (isOptionSelected(option)) { 97 | if (props.renderCheckIcon) { 98 | return props.renderCheckIcon; 99 | } else { 100 | return ; 101 | } 102 | } 103 | return null; 104 | }; 105 | 106 | const renderCreateIcon = () => { 107 | if (props.renderCreateIcon) { 108 | return props.renderCreateIcon; 109 | } else { 110 | return ; 111 | } 112 | }; 113 | 114 | return ( 115 | 116 | 117 | filterOptions(e.currentTarget.value)} 122 | ref={inputRef} 123 | isInvalid={isInvalid} 124 | /> 125 | } />} 127 | /> 128 | 129 | 130 | {displayOptions && ( 131 | 138 | {partialResult?.map((option) => { 139 | return ( 140 | selectOptionFromList(option)} 147 | > 148 | 149 | {renderCheckIcon(option)} 150 | {option.label} 151 | 152 | 153 | ); 154 | })} 155 | {!partialResult?.length && allowCreation && ( 156 | createOption()} 163 | > 164 | 165 | {renderCreateIcon()} 166 | {createText} 167 | 168 | 169 | )} 170 | {!partialResult?.length && !allowCreation && ( 171 | 172 | {notFoundText} 173 | 174 | )} 175 | 176 | )} 177 | 178 | ); 179 | }; 180 | 181 | Autocomplete.defaultProps = { 182 | notFoundText: 'Not found', 183 | allowCreation: true, 184 | createText: 'Create option', 185 | }; 186 | 187 | export default Autocomplete; 188 | -------------------------------------------------------------------------------- /src/ui/app/components/avatarLoader.jsx: -------------------------------------------------------------------------------- 1 | import { Box, Image } from '@chakra-ui/react'; 2 | import React from 'react'; 3 | import { avatarToImage } from '../../../api/extension'; 4 | 5 | const AvatarLoader = ({ avatar, width, smallRobot }) => { 6 | const [loaded, setLoaded] = React.useState(''); 7 | 8 | const fetchAvatar = async () => { 9 | if (!avatar || avatar === loaded) return; 10 | setLoaded(Number(avatar) ? avatarToImage(avatar) : avatar); 11 | }; 12 | 13 | React.useEffect(() => { 14 | fetchAvatar(); 15 | }, [avatar]); 16 | return ( 17 | 26 | ); 27 | }; 28 | 29 | export default AvatarLoader; 30 | -------------------------------------------------------------------------------- /src/ui/app/components/collectible.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Avatar, 4 | Image, 5 | Skeleton, 6 | useColorModeValue, 7 | } from '@chakra-ui/react'; 8 | import React from 'react'; 9 | import './styles.css'; 10 | import { getAsset } from '../../../api/extension'; 11 | import { useCaptureEvent } from '../../../features/analytics/hooks'; 12 | import { Events } from '../../../features/analytics/events'; 13 | 14 | const useIsMounted = () => { 15 | const isMounted = React.useRef(false); 16 | React.useEffect(() => { 17 | isMounted.current = true; 18 | return () => (isMounted.current = false); 19 | }, []); 20 | return isMounted; 21 | }; 22 | 23 | const Collectible = React.forwardRef(({ asset }, ref) => { 24 | const capture = useCaptureEvent(); 25 | const isMounted = useIsMounted(); 26 | const [token, setToken] = React.useState(null); 27 | const background = useColorModeValue('gray.300', 'white'); 28 | const [showInfo, setShowInfo] = React.useState(false); 29 | 30 | const fetchMetadata = async () => { 31 | const detailedConstructedAsset = await getAsset(asset.unit); 32 | const detailedAsset = { 33 | ...detailedConstructedAsset, 34 | quantity: asset.quantity, 35 | fingerprint: asset.fingerprint ?? detailedConstructedAsset.fingerprint, 36 | }; 37 | if (!isMounted.current) return; 38 | setToken(detailedAsset); 39 | }; 40 | 41 | React.useEffect(() => { 42 | fetchMetadata(); 43 | }, [asset]); 44 | 45 | return ( 46 | <> 47 | { 49 | capture(Events.NFTsImageClick); 50 | token && ref.current.openModal(token); 51 | }} 52 | position="relative" 53 | display="flex" 54 | alignItems="center" 55 | justifyContent="center" 56 | flexDirection="column" 57 | width="160px" 58 | height="160px" 59 | overflow="hidden" 60 | rounded="3xl" 61 | background={background} 62 | border="solid 1px" 63 | borderColor={background} 64 | onMouseEnter={() => setShowInfo(true)} 65 | onMouseLeave={() => setShowInfo(false)} 66 | cursor="pointer" 67 | userSelect="none" 68 | > 69 | 76 | {!token ? ( 77 | 78 | ) : ( 79 | 86 | ) : ( 87 | 88 | ) 89 | } 90 | /> 91 | )} 92 | 93 | {token && ( 94 | 104 | 115 | 124 | {token.displayName} 125 | 126 | 133 | x {token.quantity} 134 | 135 | 136 | 137 | )} 138 | 139 | 140 | ); 141 | }); 142 | 143 | const Fallback = ({ name }) => { 144 | const [timedOut, setTimedOut] = React.useState(false); 145 | const isMounted = useIsMounted(); 146 | React.useEffect(() => { 147 | setTimeout(() => isMounted.current && setTimedOut(true), 30000); 148 | }, []); 149 | if (timedOut) return ; 150 | return ; 151 | }; 152 | 153 | export default Collectible; 154 | -------------------------------------------------------------------------------- /src/ui/app/components/copy.jsx: -------------------------------------------------------------------------------- 1 | import { Box, Tooltip } from '@chakra-ui/react'; 2 | import React from 'react'; 3 | 4 | const Copy = ({ label, copy, onClick, ...props }) => { 5 | const [copied, setCopied] = React.useState(false); 6 | return ( 7 | 8 | { 11 | if (onClick) onClick(); 12 | navigator.clipboard.writeText(copy); 13 | setCopied(true); 14 | setTimeout(() => setCopied(false), 800); 15 | }} 16 | > 17 | {props.children} 18 | 19 | 20 | ); 21 | }; 22 | 23 | export default Copy; 24 | -------------------------------------------------------------------------------- /src/ui/app/components/historyViewer.jsx: -------------------------------------------------------------------------------- 1 | import { Box, Text, Spinner, Accordion, Button } from '@chakra-ui/react'; 2 | import { ChevronDownIcon } from '@chakra-ui/icons'; 3 | import React from 'react'; 4 | import { File } from 'react-kawaii'; 5 | import { 6 | getTransactions, 7 | setTransactions, 8 | setTxDetail, 9 | } from '../../../api/extension'; 10 | import Transaction from './transaction'; 11 | import { useCaptureEvent } from '../../../features/analytics/hooks'; 12 | import { Events } from '../../../features/analytics/events'; 13 | 14 | const BATCH = 5; 15 | 16 | let slice = []; 17 | 18 | let txObject = {}; 19 | 20 | const HistoryViewer = ({ history, network, currentAddr, addresses }) => { 21 | const capture = useCaptureEvent(); 22 | const [historySlice, setHistorySlice] = React.useState(null); 23 | const [page, setPage] = React.useState(1); 24 | const [final, setFinal] = React.useState(false); 25 | const [loadNext, setLoadNext] = React.useState(false); 26 | const getTxs = async () => { 27 | if (!history) { 28 | slice = []; 29 | setHistorySlice(null); 30 | setPage(1); 31 | setFinal(false); 32 | return; 33 | } 34 | await new Promise((res, rej) => setTimeout(() => res(), 10)); 35 | slice = slice.concat( 36 | history.confirmed.slice((page - 1) * BATCH, page * BATCH) 37 | ); 38 | 39 | if (slice.length < page * BATCH) { 40 | const txs = await getTransactions(page, BATCH); 41 | 42 | if (txs.length <= 0) { 43 | setFinal(true); 44 | } else { 45 | slice = Array.from(new Set(slice.concat(txs.map((tx) => tx.txHash)))); 46 | await setTransactions(slice); 47 | } 48 | } 49 | if (slice.length < page * BATCH) setFinal(true); 50 | setHistorySlice(slice); 51 | }; 52 | 53 | React.useEffect(() => { 54 | getTxs(); 55 | }, [history, page]); 56 | 57 | React.useEffect(() => { 58 | const storeTx = setInterval(() => { 59 | if (Object.keys(txObject).length <= 0) return; 60 | setTimeout(() => setTxDetail(txObject)); 61 | }, 1000); 62 | return () => { 63 | slice = []; 64 | setHistorySlice(null); 65 | setPage(1); 66 | setFinal(false); 67 | clearInterval(storeTx); 68 | }; 69 | }, []); 70 | 71 | React.useEffect(() => { 72 | if (!historySlice) return; 73 | if (historySlice.length >= (page - 1) * BATCH) setLoadNext(false); 74 | }, [historySlice]); 75 | 76 | return ( 77 | 78 | {!(history && historySlice) ? ( 79 | 80 | ) : historySlice.length <= 0 ? ( 81 | 89 | 90 | 91 | 92 | No History 93 | 94 | 95 | ) : ( 96 | <> 97 | { 101 | capture(Events.ActivityActivityActivityRowClick); 102 | }} 103 | > 104 | {historySlice.map((txHash, index) => { 105 | if (!history.details[txHash]) history.details[txHash] = {}; 106 | 107 | return ( 108 | { 110 | txObject[txHash] = txDetail; 111 | }} 112 | key={index} 113 | txHash={txHash} 114 | detail={history.details[txHash]} 115 | currentAddr={currentAddr} 116 | addresses={addresses} 117 | network={network} 118 | /> 119 | ); 120 | })} 121 | 122 | {final ? ( 123 | 130 | ... nothing more 131 | 132 | ) : ( 133 | 134 | 149 | 150 | )} 151 | 152 | )} 153 | 154 | ); 155 | }; 156 | 157 | const HistorySpinner = () => ( 158 | 159 | 160 | 161 | ); 162 | 163 | export default HistoryViewer; 164 | -------------------------------------------------------------------------------- /src/ui/app/components/qrCode.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import QRCodeStyling from 'qr-code-styling'; 3 | import Ada from '../../../assets/img/ada.png'; 4 | import { useColorModeValue } from '@chakra-ui/react'; 5 | 6 | const qrCode = new QRCodeStyling({ 7 | width: 150, 8 | height: 150, 9 | image: Ada, 10 | dotsOptions: { 11 | color: '#319795', 12 | type: 'dots', 13 | }, 14 | cornersSquareOptions: { type: 'extra-rounded', color: '#DD6B20' }, 15 | imageOptions: { 16 | crossOrigin: 'anonymous', 17 | margin: 8, 18 | }, 19 | }); 20 | 21 | const QrCode = ({ value }) => { 22 | const ref = React.useRef(null); 23 | const bgColor = useColorModeValue('white', '#2D3748'); 24 | const contentColor = useColorModeValue( 25 | { corner: '#DD6B20', dots: '#319795' }, 26 | { corner: '#FBD38D', dots: '#81E6D9' } 27 | ); 28 | 29 | React.useEffect(() => { 30 | qrCode.append(ref.current); 31 | }, []); 32 | 33 | React.useEffect(() => { 34 | qrCode.update({ 35 | data: value, 36 | backgroundOptions: { 37 | color: bgColor, 38 | }, 39 | dotsOptions: { 40 | color: contentColor.dots, 41 | }, 42 | cornersSquareOptions: { color: contentColor.corner }, 43 | }); 44 | }, [value, bgColor]); 45 | 46 | return
; 47 | }; 48 | 49 | export default QrCode; 50 | -------------------------------------------------------------------------------- /src/ui/app/components/scrollbar.jsx: -------------------------------------------------------------------------------- 1 | export { Scrollbars } from 'react-custom-scrollbars-2'; 2 | -------------------------------------------------------------------------------- /src/ui/app/components/styles.css: -------------------------------------------------------------------------------- 1 | .lineClamp { 2 | display: -webkit-box; 3 | -webkit-line-clamp: 2; 4 | -webkit-box-orient: vertical; 5 | } 6 | 7 | .lineClamp3 { 8 | display: -webkit-box; 9 | -webkit-line-clamp: 3; 10 | -webkit-box-orient: vertical; 11 | } 12 | 13 | body::-webkit-scrollbar { 14 | display: none; 15 | } 16 | -------------------------------------------------------------------------------- /src/ui/app/components/trezorWidget.jsx: -------------------------------------------------------------------------------- 1 | import { CloseIcon } from '@chakra-ui/icons'; 2 | import { Box, Modal, ModalContent, ModalOverlay, useDisclosure } from '@chakra-ui/react'; 3 | import React from 'react'; 4 | 5 | const TrezorWidget = React.forwardRef((props, ref) => { 6 | const { isOpen, onOpen, onClose } = useDisclosure(); 7 | React.useImperativeHandle(ref, () => ({ 8 | openModal() { 9 | onOpen(); 10 | }, 11 | closeModal() { 12 | onClose(); 13 | }, 14 | })); 15 | return ( 16 | 22 | 23 | 32 | 40 | 49 | 50 |