diff --git a/.env.example b/.env.example index c8476d06..b2804bb7 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,14 @@ NEXT_PUBLIC_BEAMER_ID= # Wallet-specific variables NEXT_PUBLIC_WC_BRIDGE= +NEXT_PUBLIC_WC_PROJECT_ID= # E2E tests -NEXT_PUBLIC_CYPRESS_MNEMONIC= \ No newline at end of file +NEXT_PUBLIC_CYPRESS_MNEMONIC= + +# Safe Gelato relay service +NEXT_PUBLIC_SAFE_GELATO_RELAY_SERVICE_URL_PRODUCTION= +NEXT_PUBLIC_SAFE_GELATO_RELAY_SERVICE_URL_STAGING= + +# Redefine +NEXT_PUBLIC_REDEFINE_API= \ No newline at end of file diff --git a/.github/workflows/build/action.yml b/.github/workflows/build/action.yml index 99b2a347..df3b0c68 100644 --- a/.github/workflows/build/action.yml +++ b/.github/workflows/build/action.yml @@ -6,7 +6,7 @@ inputs: secrets: required: true - prod: # id of input + prod: # id of input description: 'Production build flag' required: false @@ -22,6 +22,9 @@ runs: run: yarn build && yarn export env: NEXT_PUBLIC_IS_PRODUCTION: ${{ inputs.prod }} + NEXT_PUBLIC_GATEWAY_URL_PRODUCTION: ${{ fromJSON(inputs.secrets).NEXT_PUBLIC_GATEWAY_URL_PRODUCTION }} + NEXT_PUBLIC_GATEWAY_URL_STAGING: ${{ fromJSON(inputs.secrets).NEXT_PUBLIC_GATEWAY_URL_STAGING }} + NEXT_PUBLIC_SAFE_VERSION: ${{ fromJSON(inputs.secrets).NEXT_PUBLIC_SAFE_VERSION }} NEXT_PUBLIC_BEAMER_ID: ${{ fromJSON(inputs.secrets).NEXT_PUBLIC_BEAMER_ID }} NEXT_PUBLIC_GOOGLE_TAG_MANAGER_DEVELOPMENT_AUTH: ${{ fromJSON(inputs.secrets).NEXT_PUBLIC_GOOGLE_TAG_MANAGER_DEVELOPMENT_AUTH }} NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID: ${{ fromJSON(inputs.secrets).NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID }} @@ -34,4 +37,9 @@ runs: NEXT_PUBLIC_TENDERLY_PROJECT_NAME: ${{ fromJSON(inputs.secrets).NEXT_PUBLIC_TENDERLY_PROJECT_NAME }} NEXT_PUBLIC_TENDERLY_SIMULATE_ENDPOINT_URL: ${{ fromJSON(inputs.secrets).NEXT_PUBLIC_TENDERLY_SIMULATE_ENDPOINT_URL }} NEXT_PUBLIC_WC_BRIDGE: ${{ fromJSON(inputs.secrets).NEXT_PUBLIC_WC_BRIDGE }} + NEXT_PUBLIC_WC_PROJECT_ID: ${{ fromJSON(inputs.secrets).NEXT_PUBLIC_WC_PROJECT_ID }} NEXT_PUBLIC_CYPRESS_MNEMONIC: ${{ inputs.e2e_mnemonic }} + NEXT_PUBLIC_SAFE_RELAY_SERVICE_URL_PRODUCTION: ${{ fromJSON(inputs.secrets).NEXT_PUBLIC_SAFE_GELATO_RELAY_SERVICE_URL_PRODUCTION }} + NEXT_PUBLIC_SAFE_RELAY_SERVICE_URL_STAGING: ${{ fromJSON(inputs.secrets).NEXT_PUBLIC_SAFE_GELATO_RELAY_SERVICE_URL_STAGING }} + NEXT_PUBLIC_IS_OFFICIAL_HOST: ${{ fromJSON(inputs.secrets).NEXT_PUBLIC_IS_OFFICIAL_HOST }} + NEXT_PUBLIC_REDEFINE_API: ${{ fromJSON(inputs.secrets).NEXT_PUBLIC_REDEFINE_API }} diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml index 4605ac40..f8681c53 100644 --- a/.github/workflows/cla.yml +++ b/.github/workflows/cla.yml @@ -20,7 +20,7 @@ jobs: PERSONAL_ACCESS_TOKEN: ${{ secrets.CLA_ACCESS_TOKEN }} with: path-to-signatures: 'signatures/version1/cla.json' - path-to-document: 'https://safe.global/cla/' # e.g. a CLA or a DCO document + path-to-document: 'https://safe.global/cla' # e.g. a CLA or a DCO document # branch should not be protected branch: 'main' # user names of users allowed to contribute without CLA diff --git a/.github/workflows/deploy-dockerhub.yml b/.github/workflows/deploy-dockerhub.yml index 959bf54c..87719b0a 100644 --- a/.github/workflows/deploy-dockerhub.yml +++ b/.github/workflows/deploy-dockerhub.yml @@ -25,14 +25,14 @@ jobs: if: github.ref == 'refs/heads/main' run: bash scripts/github/deploy_docker.sh staging env: - DOCKERHUB_PROJECT: web-core + DOCKERHUB_PROJECT: ${{ secrets.DOCKER_PROJECT }} - name: Deploy Dockerhub dev if: github.ref == 'refs/heads/dev' run: bash scripts/github/deploy_docker.sh dev env: - DOCKERHUB_PROJECT: web-core + DOCKERHUB_PROJECT: ${{ secrets.DOCKER_PROJECT }} - name: Deploy Dockerhub tag if: startsWith(github.ref, 'refs/tags/') run: bash scripts/github/deploy_docker.sh ${GITHUB_REF##*/} env: - DOCKERHUB_PROJECT: web-core + DOCKERHUB_PROJECT: ${{ secrets.DOCKER_PROJECT }} diff --git a/.github/workflows/deploy-release.yml b/.github/workflows/deploy-release.yml index 4d23d9e6..4bed005b 100644 --- a/.github/workflows/deploy-release.yml +++ b/.github/workflows/deploy-release.yml @@ -11,11 +11,6 @@ jobs: env: ARCHIVE_NAME: ${{ github.event.repository.name }}-${{ github.event.release.tag_name }} steps: - - name: Cancel previous runs - uses: styfle/cancel-workflow-action@0.9.1 - with: - access_token: ${{ github.token }} - - uses: actions/checkout@v3 - uses: ./.github/workflows/yarn diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index e13ce2b2..fd10d81a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -8,6 +8,10 @@ on: - dev - main +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: deploy: runs-on: ubuntu-latest @@ -17,11 +21,6 @@ jobs: name: Deploy to dev/staging steps: - - name: Cancel previous runs - uses: styfle/cancel-workflow-action@0.9.1 - with: - access_token: ${{ github.token }} - # Post a PR comment before deploying - name: Post a comment while building if: github.event.number @@ -70,14 +69,14 @@ jobs: shell: bash ## Cut off "refs/heads/" and only allow alphanumeric characters, ## e.g. "refs/heads/features/hello-1.2.0" -> "features_hello_1_2_0" - run: echo "##[set-output name=branch;]$(echo $GITHUB_HEAD_REF | sed 's/refs\/heads\///' | sed 's/[^a-z0-9]/_/ig')" + run: echo "branch=$(echo $GITHUB_HEAD_REF | sed 's/refs\/heads\///' | sed 's/[^a-z0-9]/_/ig')" >> $GITHUB_OUTPUT id: extract_branch # Deploy to S3 - name: Deploy PR branch if: github.event.number env: - BUCKET: s3://${{ secrets.AWS_REVIEW_BUCKET_NAME }}/webcore/${{ steps.extract_branch.outputs.branch }} + BUCKET: s3://${{ secrets.AWS_REVIEW_BUCKET_NAME }}/walletweb/${{ steps.extract_branch.outputs.branch }} run: bash ./scripts/github/s3_upload.sh # Comnment @@ -90,7 +89,7 @@ jobs: ## Branch preview ✅ Deploy successful! - https://${{ steps.extract_branch.outputs.branch }}--webcore.review-web-core.5afe.dev + https://${{ steps.extract_branch.outputs.branch }}--walletweb.review-wallet-web.5afe.dev message-failure: | ## Branch preview ❌ Deploy failed! diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index deb1e559..d9786942 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -3,16 +3,15 @@ name: e2e on: pull_request: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: e2e: runs-on: ubuntu-latest name: Smoke E2E tests steps: - - name: Cancel previous runs - uses: styfle/cancel-workflow-action@0.9.1 - with: - access_token: ${{ github.token }} - - uses: actions/checkout@v3 - uses: ./.github/workflows/yarn diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 9acdeab0..d9bf6943 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,15 +1,14 @@ name: 'Lint' on: [pull_request] +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: eslint: runs-on: ubuntu-latest steps: - - name: Cancel previous runs - uses: styfle/cancel-workflow-action@0.9.1 - with: - access_token: ${{ github.token }} - - uses: actions/checkout@v3 - uses: ./.github/workflows/yarn diff --git a/.github/workflows/safe-apps-e2e.yml b/.github/workflows/safe-apps-e2e.yml index 2ce83b35..60671848 100644 --- a/.github/workflows/safe-apps-e2e.yml +++ b/.github/workflows/safe-apps-e2e.yml @@ -4,16 +4,15 @@ on: pull_request: workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: e2e: runs-on: ubuntu-latest name: Safe Apps E2E tests steps: - - name: Cancel previous runs - uses: styfle/cancel-workflow-action@0.9.1 - with: - access_token: ${{ github.token }} - - uses: actions/checkout@v3 - uses: ./.github/workflows/yarn diff --git a/.github/workflows/tag-release.yml b/.github/workflows/tag-release.yml index 2c6aeaa7..6e23c984 100644 --- a/.github/workflows/tag-release.yml +++ b/.github/workflows/tag-release.yml @@ -11,8 +11,8 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v2 if: github.event.pull_request.merged == true + uses: actions/checkout@v2 with: fetch-depth: 0 @@ -22,23 +22,20 @@ jobs: run: | NEW_VERSION=$(node -p 'require("./package.json").version') echo "version=v$NEW_VERSION" >> $GITHUB_OUTPUT - echo "${{ github.event.pull_request.body }}" > CHANGELOG.md - name: Create a git tag if: github.event.pull_request.merged == true - run: git tag $NEW_VERSION && git push --tags - env: - NEW_VERSION: ${{ steps.version.outputs.version }} + run: git tag ${{ steps.version.outputs.version }} && git push --tags - name: GitHub release if: success() - uses: actions/create-release@v1 + uses: softprops/action-gh-release@v1 id: create_release with: draft: true prerelease: false - release_name: ${{ steps.version.outputs.version }} + name: ${{ steps.version.outputs.version }} tag_name: ${{ steps.version.outputs.version }} - body_path: CHANGELOG.md + body: ${{ github.event.pull_request.body }} env: GITHUB_TOKEN: ${{ github.token }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 210c74ed..6ac48137 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,15 +6,14 @@ on: branches: - main +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: test: runs-on: ubuntu-latest steps: - - name: Cancel previous runs - uses: styfle/cancel-workflow-action@0.9.1 - with: - access_token: ${{ github.token }} - - uses: actions/checkout@v3 - uses: ./.github/workflows/yarn diff --git a/.github/workflows/yarn/action.yml b/.github/workflows/yarn/action.yml index 25861574..4d3112fa 100644 --- a/.github/workflows/yarn/action.yml +++ b/.github/workflows/yarn/action.yml @@ -10,7 +10,6 @@ runs: with: path: '**/node_modules' key: web-core-modules-${{ hashFiles('**/yarn.lock') }} - - name: Yarn install shell: bash - run: yarn install --immutable + run: yarn install --frozen-lockfile diff --git a/.gitignore b/.gitignore index c7d758f8..9166d3ee 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,7 @@ yalc.lock .env /cypress/videos +/cypress/screenshots /cypress/downloads /public/sw.js diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e2a03ff1..21c32b2e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,7 @@ Please note we have a Code of Conduct (see below), please follow it in all your ## CLA -It is a requirement for all contributors to sign the [Contributor License Agreement (CLA)](safe.global/cla/) in order to proceed with their contribution. +It is a requirement for all contributors to sign the [Contributor License Agreement (CLA)](https://safe.global/cla) in order to proceed with their contribution. ## Pull Request Process diff --git a/README.md b/README.md index 51342cec..89d7f458 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ -# Safe Web Core +# Safe{Wallet} -[![License](https://img.shields.io/github/license/safe-global/web-core)](https://github.com/safe-global/web-core/blob/main/LICENSE) -![Tests](https://img.shields.io/github/actions/workflow/status/safe-global/web-core/test.yml?branch=main&label=tests) -![GitHub package.json version (branch)](https://img.shields.io/github/package-json/v/safe-global/web-core) -[![GitPOAP Badge](https://public-api.gitpoap.io/v1/repo/safe-global/web-core/badge)](https://www.gitpoap.io/gh/safe-global/web-core) +[![License](https://img.shields.io/github/license/safe-global/safe-wallet-web)](https://github.com/safe-global/safe-wallet-web/blob/main/LICENSE) +![Tests](https://img.shields.io/github/actions/workflow/status/safe-global/safe-wallet-web/test.yml?branch=main&label=tests) +![GitHub package.json version (branch)](https://img.shields.io/github/package-json/v/safe-global/safe-wallet-web) +[![GitPOAP Badge](https://public-api.gitpoap.io/v1/repo/safe-global/safe-wallet-web/badge)](https://www.gitpoap.io/gh/safe-global/safe-wallet-web) The default Safe web interface. @@ -19,24 +19,29 @@ Create a `.env` file with environment variables. You can use the `.env.example` Here's the list of all the required and optional variables: -| Env variable | | Description | -| ------------ | -------- | ----------- | -| `NEXT_PUBLIC_IS_PRODUCTION` | optional | Set to `true` to build a minified production app | -| `NEXT_PUBLIC_GATEWAY_URL_PRODUCTION` | optional | The base URL for the [Safe Client Gateway](https://github.com/safe-global/safe-client-gateway) | -| `NEXT_PUBLIC_GATEWAY_URL_STAGING` | optional | The base CGW URL on staging | -| `NEXT_PUBLIC_SAFE_VERSION` | optional | The latest version of the Safe contract, defaults to 1.3.0 | -| `NEXT_PUBLIC_INFURA_TOKEN` | **required** | [Infura](https://docs.infura.io/infura/networks/ethereum/how-to/secure-a-project/project-id) RPC API token | -| `NEXT_PUBLIC_SAFE_APPS_INFURA_TOKEN` | optional | Infura token for Safe Apps, falls back to `NEXT_PUBLIC_INFURA_TOKEN` | -| `NEXT_PUBLIC_WC_BRIDGE` | optional | [WalletConnect](https://docs.walletconnect.com/1.0/bridge-server) bridge URL, falls back to the public WC bridge | -| `NEXT_PUBLIC_TENDERLY_ORG_NAME` | optional | [Tenderly](https://tenderly.co) org name for Transaction Simulation | -| `NEXT_PUBLIC_TENDERLY_PROJECT_NAME` | optional | Tenderly project name | -| `NEXT_PUBLIC_TENDERLY_SIMULATE_ENDPOINT_URL` | optional | Tenderly simulation URL | -| `NEXT_PUBLIC_BEAMER_ID` | optional | [Beamer](https://www.getbeamer.com) is a news feed for in-app announcements | -| `NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID` | optional | [GTM](https://tagmanager.google.com) project id | -| `NEXT_PUBLIC_GOOGLE_TAG_MANAGER_DEVELOPMENT_AUTH` | optional | Dev GTM key | -| `NEXT_PUBLIC_GOOGLE_TAG_MANAGER_LATEST_AUTH` | optional | Preview GTM key | -| `NEXT_PUBLIC_GOOGLE_TAG_MANAGER_LIVE_AUTH` | optional | Production GTM key | -| `NEXT_PUBLIC_SENTRY_DSN` | optional | [Sentry](https://sentry.io) id for tracking runtime errors | +| Env variable | | Description +| ------------------------------------------------------ | ------------ | ----------- +| `NEXT_PUBLIC_INFURA_TOKEN` | **required** | [Infura](https://docs.infura.io/infura/networks/ethereum/how-to/secure-a-project/project-id) RPC API token +| `NEXT_PUBLIC_SAFE_APPS_INFURA_TOKEN` | optional | Infura token for Safe Apps, falls back to `NEXT_PUBLIC_INFURA_TOKEN` +| `NEXT_PUBLIC_IS_PRODUCTION` | optional | Set to `true` to build a minified production app +| `NEXT_PUBLIC_GATEWAY_URL_PRODUCTION` | optional | The base URL for the [Safe Client Gateway](https://github.com/safe-global/safe-client-gateway) +| `NEXT_PUBLIC_GATEWAY_URL_STAGING` | optional | The base CGW URL on staging +| `NEXT_PUBLIC_SAFE_VERSION` | optional | The latest version of the Safe contract, defaults to 1.3.0 +| `NEXT_PUBLIC_WC_BRIDGE` | optional | [WalletConnect v1](https://docs.walletconnect.com/1.0/bridge-server) bridge URL, falls back to the public WC bridge +| `NEXT_PUBLIC_WC_PROJECT_ID` | optional | [WalletConnect v2](https://docs.walletconnect.com/2.0/cloud/relay) project ID +| `NEXT_PUBLIC_TENDERLY_ORG_NAME` | optional | [Tenderly](https://tenderly.co) org name for Transaction Simulation +| `NEXT_PUBLIC_TENDERLY_PROJECT_NAME` | optional | Tenderly project name +| `NEXT_PUBLIC_TENDERLY_SIMULATE_ENDPOINT_URL` | optional | Tenderly simulation URL +| `NEXT_PUBLIC_BEAMER_ID` | optional | [Beamer](https://www.getbeamer.com) is a news feed for in-app announcements +| `NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID` | optional | [GTM](https://tagmanager.google.com) project id +| `NEXT_PUBLIC_GOOGLE_TAG_MANAGER_DEVELOPMENT_AUTH` | optional | Dev GTM key +| `NEXT_PUBLIC_GOOGLE_TAG_MANAGER_LATEST_AUTH` | optional | Preview GTM key +| `NEXT_PUBLIC_GOOGLE_TAG_MANAGER_LIVE_AUTH` | optional | Production GTM key +| `NEXT_PUBLIC_SENTRY_DSN` | optional | [Sentry](https://sentry.io) id for tracking runtime errors +| `NEXT_PUBLIC_SAFE_GELATO_RELAY_SERVICE_URL_PRODUCTION` | optional | [Safe Gelato Relay Service](https://github.com/safe-global/safe-gelato-relay-service) URL to allow relaying transactions via Gelato +| `NEXT_PUBLIC_SAFE_GELATO_RELAY_SERVICE_URL_STAGING` | optional | Relay URL on staging +| `NEXT_PUBLIC_IS_OFFICIAL_HOST` | optional | Whether it's the official distribution of the app, or a fork; has legal implications. Set to true only if you also update the legal pages like Imprint and Terms of use +| `NEXT_PUBLIC_REDEFINE_API` | optional | Redefine API base URL If you don't provide some of the optional vars, the corresponding features will be disabled in the UI. @@ -59,11 +64,13 @@ Open [http://localhost:3000](http://localhost:3000) with your browser to see the ## Lint ESLint: + ``` yarn lint --fix ``` Prettier: + ``` yarn prettier ``` @@ -71,22 +78,27 @@ yarn prettier ## Tests Unit tests: + ``` yarn test --watch ``` ### Cypress tests + Build and generarate a static site: + ``` yarn build && yarn export ``` Serve the static files: + ``` yarn serve ``` Launch the Cypress UI: + ``` yarn cypress:open ``` @@ -94,17 +106,22 @@ yarn cypress:open You can then choose which e2e tests to run. ## Component template + To create a new component from a template: + ``` yarn cmp MyNewComponent ``` ## Frameworks - * [Safe Core SDK](https://github.com/safe-global/safe-core-sdk) - * [Safe Gateway SDK](https://github.com/safe-global/safe-gateway-typescript-sdk) - * Next.js - * React - * Redux - * MUI - * ethers.js - * web3-onboard + +This app is built using the following frameworks: + +- [Safe Core SDK](https://github.com/safe-global/safe-core-sdk) +- [Safe Gateway SDK](https://github.com/safe-global/safe-gateway-typescript-sdk) +- Next.js +- React +- Redux +- MUI +- ethers.js +- web3-onboard diff --git a/cypress.config.js b/cypress.config.js index 99f64873..16a50834 100644 --- a/cypress.config.js +++ b/cypress.config.js @@ -5,7 +5,7 @@ export default defineConfig({ trashAssetsBeforeRuns: true, retries: { - runMode: 2, + runMode: 3, openMode: 0, }, diff --git a/cypress/e2e/safe-apps/apps_list.cy.js b/cypress/e2e/safe-apps/apps_list.cy.js index 77eb75b3..87ae81ff 100644 --- a/cypress/e2e/safe-apps/apps_list.cy.js +++ b/cypress/e2e/safe-apps/apps_list.cy.js @@ -22,7 +22,7 @@ describe('The Safe Apps list', () => { it('should show a not found text when no match', () => { cy.findByRole('textbox').clear().type('atextwithoutresults') - cy.findByText(/no apps found/i).should('exist') + cy.findByText(/no Safe Apps found/i).should('exist') }) }) @@ -31,7 +31,7 @@ describe('The Safe Apps list', () => { cy.findByRole('textbox').clear() cy.findByLabelText(/pin walletconnect/i).click() cy.findByLabelText(/pin transaction builder/i).click() - cy.findByText(/bookmarked apps/i).click() + cy.findByText(/bookmarked Apps/i).click() cy.findByText('ALL (2)').should('exist') }) @@ -51,9 +51,9 @@ describe('The Safe Apps list', () => { cy.intercept('GET', 'https://my-invalid-custom-app.com/manifest.json', { name: 'My Custom App', }) - cy.findByText(/my custom apps/i).click() - cy.findByText(/add custom app/i).click({ force: true }) - cy.findByLabelText(/app url/i) + cy.findByText(/my custom Apps/i).click() + cy.findByText(/add custom Safe App/i).click({ force: true }) + cy.findByLabelText(/Safe App url/i) .clear() .type('https://my-invalid-custom-app.com') cy.contains("The app doesn't support Safe App functionality").should('exist') @@ -66,7 +66,7 @@ describe('The Safe Apps list', () => { icons: [{ src: 'logo.svg', sizes: 'any', type: 'image/svg+xml' }], }) - cy.findByLabelText(/app url/i) + cy.findByLabelText(/Safe App url/i) .clear() .type('https://my-valid-custom-app.com') cy.findByRole('heading', { name: /my custom app/i }).should('exist') diff --git a/cypress/e2e/safe-apps/info_modal.cy.js b/cypress/e2e/safe-apps/info_modal.cy.js index 3403d854..5f6ea1e1 100644 --- a/cypress/e2e/safe-apps/info_modal.cy.js +++ b/cypress/e2e/safe-apps/info_modal.cy.js @@ -9,7 +9,7 @@ describe('The Safe Apps info modal', () => { describe('when opening a Safe App', () => { it('should show the disclaimer', () => { cy.findByRole('link', { name: /logo.*walletconnect/i }).click() - cy.findByRole('link', { name: /open app/i }).click() + cy.findByRole('link', { name: /open Safe App/i }).click() cy.findByRole('heading', { name: /disclaimer/i }).should('exist') }) diff --git a/cypress/e2e/safe-apps/permissions_settings.cy.js b/cypress/e2e/safe-apps/permissions_settings.cy.js index 1d2b0bc4..7093480e 100644 --- a/cypress/e2e/safe-apps/permissions_settings.cy.js +++ b/cypress/e2e/safe-apps/permissions_settings.cy.js @@ -42,6 +42,7 @@ describe('The Safe Apps permissions settings section', () => { }) cy.visit(`${TEST_SAFE}/settings/safe-apps`, { failOnStatusCode: false }) + cy.findByText(/accept selection/i).click() }) it('should show the permissions configuration for each stored app', () => { diff --git a/cypress/e2e/smoke/address_book.cy.js b/cypress/e2e/smoke/address_book.cy.js index 6a51e810..442c8983 100644 --- a/cypress/e2e/smoke/address_book.cy.js +++ b/cypress/e2e/smoke/address_book.cy.js @@ -19,7 +19,7 @@ const GNO_CSV_ENTRY = { describe('Address book', () => { before(() => { - cy.visit(`/${GOERLI_TEST_SAFE}/address-book`, { failOnStatusCode: false }) + cy.visit(`/address-book?safe=${GOERLI_TEST_SAFE}`) cy.contains('Accept selection').click() // Waits for the Address Book table to be in the page cy.contains('p', 'Address book').should('be.visible') @@ -84,7 +84,7 @@ describe('Address book', () => { cy.contains('Gnosis Chain').click() // Navigate to the Address Book page - cy.visit(`/${GNO_TEST_SAFE}/address-book`, { failOnStatusCode: false }) + cy.visit(`/address-book?safe=${GNO_TEST_SAFE}`) // Waits for the Address Book table to be in the page cy.contains('p', 'Address book').should('be.visible') diff --git a/cypress/e2e/smoke/balances.cy.js b/cypress/e2e/smoke/balances.cy.js index 617d8a35..3702e556 100644 --- a/cypress/e2e/smoke/balances.cy.js +++ b/cypress/e2e/smoke/balances.cy.js @@ -2,9 +2,8 @@ const assetsTable = '[aria-labelledby="tableTitle"] > tbody' const balanceSingleRow = '[aria-labelledby="tableTitle"] > tbody tr' const TEST_SAFE = 'gor:0x97d314157727D517A706B5D08507A1f9B44AaaE9' -// TODO: replace PAGINATION_TEST_SAFE for a Görli safe with > 25 tokens -const PAGINATION_TEST_SAFE = 'rin:0x656c1121a6f40d25C5CFfF0Db08938DB7633B2A3' -const ASSETS_LENGTH = 7 +const PAGINATION_TEST_SAFE = 'gor:0x850493a15914aAC05a821A3FAb973b4598889A7b' +const ASSETS_LENGTH = 8 const ASSET_NAME_COLUMN = 0 const TOKEN_AMOUNT_COLUMN = 1 const FIAT_AMOUNT_COLUMN = 2 @@ -14,28 +13,24 @@ describe('Assets > Coins', () => { const fiatRegex = new RegExp(`([0-9]{1,3},)*[0-9]{1,3}.[0-9]{2}`) before(() => { + cy.disableProdCGW() + // Open the Safe used for testing - cy.visit(`/${TEST_SAFE}/balances`, { failOnStatusCode: false }) + cy.visit(`/balances?safe=${TEST_SAFE}`) cy.contains('button', 'Accept selection').click() // Table is loaded cy.contains('Görli Ether') cy.contains('button', 'Got it').click() + + cy.get(balanceSingleRow).should('have.length.lessThan', ASSETS_LENGTH) + cy.contains('div', 'Default tokens').click() + cy.wait(100) + cy.contains('div', 'All tokens').click() + cy.get(balanceSingleRow).should('have.length', ASSETS_LENGTH) }) describe('should have different tokens', () => { - it(`should have ${ASSETS_LENGTH} entries in the table`, () => { - // "Spam" tokens filtered - cy.get(balanceSingleRow).should('have.length', 3) - - // Enable all tokens - cy.contains('div', 'Default tokens').click() - cy.wait(100) - cy.contains('div', 'All tokens').click() - - cy.get(balanceSingleRow).should('have.length', ASSETS_LENGTH) - }) - it('should have Dai', () => { // Row should have an image with alt text "Dai" cy.contains('Dai') @@ -175,18 +170,48 @@ describe('Assets > Coins', () => { }) }) - describe.skip('pagination should work', () => { + describe('tokens can be manually hidden', () => { + it('hide single token', () => { + // Click hide Dai + cy.contains('Dai').parents('tr').find('button[aria-label="Hide asset"]').click() + // time to hide the asset + cy.wait(350) + cy.contains('Dai').should('not.exist') + }) + + it('unhide hidden token', () => { + // Open hide token menu + cy.contains('1 hidden token').click() + // uncheck dai token + cy.contains('Dai').parents('tr').find('input[type="checkbox"]').click() + // apply changes + cy.contains('Save').click() + // Dai token is visible again + cy.contains('Dai') + // The menu button shows "Hide tokens" label again + cy.contains('Hide tokens') + }) + }) + + describe('pagination should work', () => { before(() => { // Open the Safe used for testing pagination - cy.visit(`/${PAGINATION_TEST_SAFE}/balances`, { failOnStatusCode: false }) + cy.visit(`/balances?safe=${PAGINATION_TEST_SAFE}`) + cy.contains('button', 'Accept selection').click() + // Table is loaded cy.contains('Görli Ether') + cy.contains('button', 'Got it').click() + // Enable all tokens + cy.contains('div', 'Default tokens').click() + cy.wait(100) + cy.contains('div', 'All tokens').click() }) it('should allow changing rows per page and navigate to next and previous page', () => { // Table should have 25 rows inittially cy.contains('Rows per page:').next().contains('25') - cy.contains('1–25 of 27') + cy.contains('1–25 of') cy.get(balanceSingleRow).should('have.length', 25) // Change to 10 rows per page @@ -196,22 +221,22 @@ describe('Assets > Coins', () => { // Table should have 10 rows cy.contains('Rows per page:').next().contains('10') - cy.contains('1–10 of 27') + cy.contains('1–10 of') cy.get(balanceSingleRow).should('have.length', 10) // Click on the next page button cy.get('button[aria-label="Go to next page"]').click({ force: true }) cy.get('button[aria-label="Go to next page"]').click({ force: true }) - // Table should have 7 rows - cy.contains('21–27 of 27') - cy.get(balanceSingleRow).should('have.length', 7) + // Table should have N rows + cy.contains('21–28 of') + cy.get(balanceSingleRow).should('have.length', ASSETS_LENGTH) // Click on the previous page button cy.get('button[aria-label="Go to previous page"]').click({ force: true }) // Table should have 10 rows - cy.contains('11–20 of 27') + cy.contains('11–20 of') cy.get(balanceSingleRow).should('have.length', 10) }) }) diff --git a/cypress/e2e/smoke/beamer.cy.js b/cypress/e2e/smoke/beamer.cy.js index 5f723c81..6267ba94 100644 --- a/cypress/e2e/smoke/beamer.cy.js +++ b/cypress/e2e/smoke/beamer.cy.js @@ -3,30 +3,29 @@ const TEST_SAFE = 'gor:0x97d314157727D517A706B5D08507A1f9B44AaaE9' describe('Beamer', () => { it('should require accept "Updates" cookies to display Beamer', () => { // Disable PWA, otherwise it will throw a security error - cy.visit(`/${TEST_SAFE}/address-book`, { failOnStatusCode: false }) + cy.visit(`/address-book?safe=${TEST_SAFE}`) // Way to select the cookies banner without an id - cy.contains('We use cookies to provide') cy.contains('Accept selection').click() - cy.contains('We use cookies to provide').should('not.exist') // Open What's new cy.contains("What's new").click() - // Tells that the user has to accept "Updates & Feedback" cookies - cy.contains('We use cookies to provide').parent('div').contains('accept the "Updates & Feedback"') + // Tells that the user has to accept "Beamer" cookies + cy.contains('accept the "Beamer" cookies') - // "Updates" is checked when the banner opens - cy.contains('We use cookies to provide').parent('div').get('input[name="updates"]').should('be.checked') + // "Beamer" is checked when the banner opens + cy.get('input[id="beamer"]').should('be.checked') // Accept "Updates & Feedback" cookies cy.contains('Accept selection').click() - cy.contains('We use cookies to provide').should('not.exist') + cy.contains('Accept selection').should('not.exist') // wait for Beamer cookies to be set - cy.wait(3000) + cy.wait(600) // Open What's new cy.contains("What's new").click({ force: true }) // clicks through the "lastPostTitle" + cy.get('#beamerOverlay .iframeCointaner').should('exist') }) }) diff --git a/cypress/e2e/smoke/create_safe_simple.cy.js b/cypress/e2e/smoke/create_safe_simple.cy.js index b03f4877..36f34979 100644 --- a/cypress/e2e/smoke/create_safe_simple.cy.js +++ b/cypress/e2e/smoke/create_safe_simple.cy.js @@ -8,12 +8,12 @@ describe('Create Safe form', () => { cy.visit('/welcome') // Close cookie banner - cy.contains('button', 'Accept all').click() + cy.contains('button', 'Accept selection').click() // Ensure wallet is connected to correct chain via header cy.contains('E2E Wallet @ Görli') - cy.contains('Create new Safe').click() + cy.contains('Create new Account').click() }) it('should allow setting a name', () => { @@ -31,10 +31,6 @@ describe('Create Safe form', () => { cy.get('[data-cy="create-safe-select-network"]').click() cy.contains('Ethereum').click() - // Network hint should be displayed - cy.contains('Change your wallet network').should('be.visible') - cy.contains('button', 'Next').should('be.disabled') - // Switch back to Görli cy.get('[data-cy="create-safe-select-network"]').click() cy.contains('li span', 'Görli').click() diff --git a/cypress/e2e/create_tx.cy.js b/cypress/e2e/smoke/create_tx.cy.js similarity index 65% rename from cypress/e2e/create_tx.cy.js rename to cypress/e2e/smoke/create_tx.cy.js index f03012da..ee09b6b4 100644 --- a/cypress/e2e/create_tx.cy.js +++ b/cypress/e2e/smoke/create_tx.cy.js @@ -2,15 +2,16 @@ const SAFE = 'gor:0x04f8b1EA3cBB315b87ced0E32deb5a43cC151a91' const EOA = '0xE297437d6b53890cbf004e401F3acc67c8b39665' // generate number between 0.00001 and 0.00020 -const sendValue = Math.floor(Math.random() * 20 + 1) / 100000 let recommendedNonce +const sendValue = Math.floor(Math.random() * 20 + 1) / 100000 const currentNonce = 3 describe('Queue a transaction on 1/N', () => { before(() => { cy.connectE2EWallet() + cy.useProdCGW() - cy.visit(`/${SAFE}/home`, { failOnStatusCode: false }) + cy.visit(`/home?safe=${SAFE}`) cy.contains('Accept selection').click() }) @@ -19,7 +20,9 @@ describe('Queue a transaction on 1/N', () => { // Assert that "New transaction" button is visible cy.contains('New transaction', { timeout: 60_000, // `lastWallet` takes a while initialize in CI - }).should('be.visible') + }) + .should('be.visible') + .and('not.be.disabled') // Open the new transaction modal cy.contains('New transaction').click() @@ -32,11 +35,32 @@ describe('Queue a transaction on 1/N', () => { cy.get('input[name="recipient"]').type(EOA) // Click on the Token selector cy.get('input[name="tokenAddress"]').prev().click() - cy.get('ul[role="listbox"]').contains('Görli Ether').click() + cy.get('ul[role="listbox"]') + .contains(/G(ö|oe)rli Ether/) + .click() // Insert max amount cy.contains('Max').click() + // Validates the "Max" button action, then clears and sets the actual sendValue + cy.get('input[name="tokenAddress"]') + .prev() + .find('p') + .contains(/G(ö|oe)rli Ether/) + .next() + .then((element) => { + const maxBalance = element.text().replace(' GOR', '').trim() + cy.wrap(element) + .parents('form') + .find('label') + .contains('Amount') + .next() + .find('input') + .should('have.value', maxBalance) + .clear() + .type(sendValue) + }) + cy.contains('Next').click() }) @@ -61,7 +85,7 @@ describe('Queue a transaction on 1/N', () => { // Changes nonce to next one cy.contains('Signing the transaction with nonce').click() cy.contains('button', 'Edit').click() - cy.get('label').contains('Safe transaction nonce').next().clear().type(currentNonce) + cy.get('label').contains('Safe Account transaction nonce').next().clear().type(currentNonce) cy.contains('Confirm').click() // Asserts the execute checkbox exists @@ -77,6 +101,32 @@ describe('Queue a transaction on 1/N', () => { }) cy.contains('Estimated fee').should('exist') + // Asserting the sponsored info is present + cy.contains('Sponsored by').should('be.visible') + + cy.get('span').contains('Estimated fee').next().should('have.css', 'text-decoration-line', 'line-through') + cy.contains('Transactions per hour') + cy.contains('5 of 5') + + cy.contains('Estimated fee').click() + cy.contains('Edit').click() + cy.contains('Owner transaction (Execution)').parents('form').as('Paramsform') + + // Only gaslimit should be editable when the relayer is selected + const arrayNames = ['Wallet nonce', 'Max priority fee (Gwei)', 'Max fee (Gwei)'] + arrayNames.forEach((element) => { + cy.get('@Paramsform').find('label').contains(`${element}`).next().find('input').should('be.disabled') + }) + + cy.get('@Paramsform') + .find('[name="gasLimit"]') + .clear() + .type('300000') + .invoke('prop', 'value') + .should('equal', '300000') + cy.get('@Paramsform').find('[name="gasLimit"]').parent('div').find('[data-testid="RotateLeftIcon"]').click() + cy.contains('Confirm').click() + // Asserts the execute checkbox is uncheckable cy.contains('Execute transaction').click() cy.get('@modal').within(() => { @@ -89,6 +139,10 @@ describe('Queue a transaction on 1/N', () => { expect(classListString).not.to.include('checked') }) }) + + // If the checkbox is unchecked the relayer is not present + cy.get('@modal').should('not.contain', 'Sponsored by').and('not.contain', 'Transactions per hour') + cy.contains('Signing the transaction with nonce').should('exist') // Changes back to recommended nonce @@ -114,6 +168,8 @@ describe('Queue a transaction on 1/N', () => { // Click on the notification cy.contains('View transaction').click() + //cy.contains('Queue').click() + // Single Tx page cy.contains('h3', 'Transaction details').should('be.visible') diff --git a/cypress/e2e/smoke/dashboard.cy.js b/cypress/e2e/smoke/dashboard.cy.js index 7c1a1888..8b62ed9e 100644 --- a/cypress/e2e/smoke/dashboard.cy.js +++ b/cypress/e2e/smoke/dashboard.cy.js @@ -2,8 +2,11 @@ const SAFE = encodeURIComponent('gor:0xCD4FddB8FfA90012DFE11eD4bf258861204FeEAE' describe('Dashboard', () => { before(() => { + cy.useProdCGW() + // Go to the test Safe home page - cy.visit(`/home?safe=${SAFE}`, { failOnStatusCode: false }) + cy.visit(`/home?safe=${SAFE}`) + cy.contains('button', 'Accept selection').click() // Wait for dashboard to initialize @@ -19,7 +22,8 @@ describe('Dashboard', () => { cy.contains('0xCD4FddB8FfA90012DFE11eD4bf258861204FeEAE').should('exist') cy.contains('2/3') cy.get(`a[href="/balances?safe=${SAFE}"]`).contains('View assets') - cy.contains('p', 'Tokens').next().contains('1') + // Text next to Tokens contains a number greater than 0 + cy.contains('p', 'Tokens').next().contains('4') cy.contains('p', 'NFTs').next().contains('0') }) }) diff --git a/cypress/e2e/smoke/import_export_data.cy.js b/cypress/e2e/smoke/import_export_data.cy.js new file mode 100644 index 00000000..f35de9f9 --- /dev/null +++ b/cypress/e2e/smoke/import_export_data.cy.js @@ -0,0 +1,71 @@ +import 'cypress-file-upload' +const path = require('path') +import { format } from 'date-fns' + +describe('Import Export Data', () => { + before(() => { + cy.visit(`/welcome`) + cy.contains('Accept selection').click() + // Waits for the Import button to be visible + cy.contains('button', 'Import').should('be.visible') + }) + + it('Uploads test file and access safe', () => { + cy.contains('button', 'Import').click() + //Uploads the file + cy.get('[type="file"]').attachFile('../fixtures/data_import.json') + //verifies that the modal says the amount of chains/addressbook values it uploaded + cy.contains('Added Safe Accounts on 3 chains').should('be.visible') + cy.contains('Address book for 3 chains').should('be.visible') + cy.contains('Settings').should('be.visible') + cy.contains('Bookmarked Safe Apps').should('be.visible') + cy.contains('Data import').parent().contains('button', 'Import').click() + //Click in one of the imported safes + cy.contains('safe 1 goerli').click() + }) + + it("Verify safe's address book imported data", () => { + //Verifies imported owners in the Address book + cy.contains('Address book').click() + cy.get('tbody tr:nth-child(1) td:nth-child(1)').contains('test1') + cy.get('tbody tr:nth-child(1) td:nth-child(2)').contains('0x61a0c717d18232711bC788F19C9Cd56a43cc8872') + cy.get('tbody tr:nth-child(2) td:nth-child(1)').contains('test2') + cy.get('tbody tr:nth-child(2) td:nth-child(2)').contains('0x7724b234c9099C205F03b458944942bcEBA13408') + }) + + it('Verify pinned apps', () => { + cy.get('aside').contains('li', 'Apps').click() + cy.contains('Bookmarked apps').click() + //Takes a some time to load the apps page, It waits for bookmark to be lighted up + cy.waitForSelector(() => { + return cy + .get('[aria-selected="true"] p') + .invoke('html') + .then((text) => text === 'Bookmarked apps') + }) + cy.contains('Drain Account').should('be.visible') + cy.contains('Transaction Builder').should('be.visible') + }) + + it('Verify imported data in settings', () => { + //In the settings checks the checkboxes and darkmode enabled + cy.contains('Settings').click() + cy.contains('Appearance').click() + cy.contains('label', 'Prepend chain prefix to addresses').find('input[type="checkbox"]').should('not.be.checked') + cy.contains('label', 'Copy addresses with chain prefix').find('input[type="checkbox"]').should('not.be.checked') + cy.get('main').contains('label', 'Dark mode').find('input[type="checkbox"]').should('be.checked') + }) + + it('Verifies data for export in Data tab', () => { + cy.contains('div[role="tablist"] a', 'Data').click() + cy.contains('Added Safe Accounts on 3 chains').should('be.visible') + cy.contains('Address book for 3 chains').should('be.visible') + cy.contains('Bookmarked Safe Apps').should('be.visible') + const date = format(new Date(), 'yyyy-MM-dd', { timeZone: 'UTC' }) + const fileName = `safe-${date}.json` + cy.contains('div', fileName).next().click() + const downloadsFolder = Cypress.config('downloadsFolder') + //File reading is failing in the CI. Can be tested locally + cy.readFile(path.join(downloadsFolder, fileName)).should('exist') + }) +}) diff --git a/cypress/e2e/smoke/load_safe.cy.js b/cypress/e2e/smoke/load_safe.cy.js index d96be6ab..ee8bf21e 100644 --- a/cypress/e2e/smoke/load_safe.cy.js +++ b/cypress/e2e/smoke/load_safe.cy.js @@ -20,7 +20,7 @@ describe('Load existing Safe', () => { cy.contains('Accept selection').click() // Enters Loading Safe form - cy.contains('button', 'Add existing Safe').click() + cy.contains('button', 'Add existing Account').click() cy.contains('Connect wallet & select network') }) diff --git a/cypress/e2e/smoke/nfts.cy.js b/cypress/e2e/smoke/nfts.cy.js index 57df4458..deea8919 100644 --- a/cypress/e2e/smoke/nfts.cy.js +++ b/cypress/e2e/smoke/nfts.cy.js @@ -4,7 +4,7 @@ describe('Assets > NFTs', () => { before(() => { cy.connectE2EWallet() - cy.visit(`/${TEST_SAFE}/balances/nfts`, { failOnStatusCode: false }) + cy.visit(`/balances/nfts?safe=${TEST_SAFE}`) cy.contains('button', 'Accept selection').click() cy.contains('E2E Wallet @ Görli') }) @@ -12,9 +12,6 @@ describe('Assets > NFTs', () => { describe('should have NFTs', () => { it('should have NFTs in the table', () => { cy.get('tbody tr').should('have.length', 5) - cy.contains('Please note that the links to OpenSea') - cy.contains('button', 'Got it!').click() - cy.contains('Please note that the links to OpenSea').should('not.exist') }) it('should have info in the NFT row', () => { @@ -80,9 +77,11 @@ describe('Assets > NFTs', () => { cy.contains('button', 'Next').click() // Review modal appears - cy.contains('Review transaction') + cy.contains('Review NFT transaction') cy.contains('Sending 2 NFTs from') - cy.contains('Batched transactions') + cy.wait(1000) + cy.contains('Action 1') + cy.contains('Action 2') cy.get('b:contains("safeTransferFrom")').should('have.length', 2) cy.contains('button:not([disabled])', 'Submit') }) diff --git a/cypress/e2e/smoke/pending_actions.cy.js b/cypress/e2e/smoke/pending_actions.cy.js index 15a732ab..e381212f 100644 --- a/cypress/e2e/smoke/pending_actions.cy.js +++ b/cypress/e2e/smoke/pending_actions.cy.js @@ -3,14 +3,10 @@ const SAFE = 'gor:0xCD4FddB8FfA90012DFE11eD4bf258861204FeEAE' describe('Pending actions', () => { before(() => { cy.connectE2EWallet() + cy.useProdCGW() - cy.visit('/welcome') - - // Close cookie banner - cy.contains('button', 'Accept all').click() - - // Ensure wallet is connected to correct chain via header - cy.contains('E2E Wallet @ Görli') + cy.visit(`/welcome`) + cy.contains('button', 'Accept selection').click() }) beforeEach(() => { @@ -25,7 +21,7 @@ describe('Pending actions', () => { it('should add the Safe with the pending actions', () => { // Enters Loading Safe form - cy.contains('button', 'Add existing Safe').click() + cy.contains('button', 'Add').click() cy.contains('Connect wallet & select network') // Inputs the Safe address @@ -46,8 +42,8 @@ describe('Pending actions', () => { cy.get('li').within(() => { cy.contains('0xCD4F...eEAE').should('exist') - cy.get('img[alt="E2E Wallet logo"]').next().contains('3').should('exist') - cy.get('[data-testid=CheckIcon]').next().contains('3').should('exist') + cy.get('img[alt="E2E Wallet logo"]').next().contains('2').should('exist') + cy.get('[data-testid=CheckIcon]').next().contains('2').should('exist') // click on the pending actions cy.get('[data-testid=CheckIcon]').next().click() @@ -59,12 +55,12 @@ describe('Pending actions', () => { cy.contains('h3', 'Transactions').should('be.visible') // contains 3 queued transactions - cy.get('span:contains("1 out of 2")').should('have.length', 3) + cy.get('span:contains("1 out of 2")').should('have.length', 2) // Ensure wallet is connected - cy.contains('E2E Wallet @ Görli') + cy.contains('E2E Wallet @ Goerli') // contains 3 signable transactions - cy.get('span:contains("Needs your confirmation")').should('have.length', 3) + cy.get('span:contains("Needs your confirmation")').should('have.length', 2) }) }) diff --git a/cypress/e2e/smoke/tx_history.cy.js b/cypress/e2e/smoke/tx_history.cy.js index 2381e6dd..f5da4642 100644 --- a/cypress/e2e/smoke/tx_history.cy.js +++ b/cypress/e2e/smoke/tx_history.cy.js @@ -1,13 +1,15 @@ const SAFE = 'gor:0x97d314157727D517A706B5D08507A1f9B44AaaE9' -const INCOMING = '/images/transactions/incoming.svg' -const OUTGOING = '/images/transactions/outgoing.svg' -const CONTRACT_INTERACTION = '/images/transactions/custom.svg' +const INCOMING = 'Received' +const OUTGOING = 'Sent' +const CONTRACT_INTERACTION = 'Contract interaction' describe('Transaction history', () => { before(() => { + cy.useProdCGW() + // Go to the test Safe transaction history - cy.visit(`/${SAFE}/transactions/history`, { failOnStatusCode: false }) + cy.visit(`/transactions/history?safe=${SAFE}`) cy.contains('button', 'Accept selection').click() }) @@ -31,7 +33,7 @@ describe('Transaction history', () => { .last() .within(() => { // Type - cy.get('img').should('have.attr', 'src', INCOMING) + cy.get('iframe').should('have.attr', 'title', INCOMING) cy.contains('div', 'Received').should('exist') // Info @@ -103,7 +105,7 @@ describe('Transaction history', () => { .prev() .within(() => { // Type - cy.get('img').should('have.attr', 'src', OUTGOING) + cy.get('iframe').should('have.attr', 'title', OUTGOING) cy.contains('div', 'Sent').should('exist') // Info @@ -131,4 +133,28 @@ describe('Transaction history', () => { cy.contains('span', 'Success').should('exist') }) }) + + it('should expand/collapse all actions', () => { + // Open the tx details + cy.contains('div', 'Mar 24, 2023') + .next() + .click() + .within(() => { + cy.contains('True').should('not.be.visible') + cy.contains('1337').should('not.be.visible') + cy.contains('5688').should('not.be.visible') + cy.contains('Expand all').click() + + // All the values in the actions must be visible + cy.contains('True').should('exist') + cy.contains('1337').should('exist') + cy.contains('5688').should('exist') + + // After collapse all the same values should not be visible + cy.contains('Collapse all').click() + cy.contains('True').should('not.be.visible') + cy.contains('1337').should('not.be.visible') + cy.contains('5688').should('not.be.visible') + }) + }) }) diff --git a/cypress/fixtures/data_import.json b/cypress/fixtures/data_import.json new file mode 100644 index 00000000..b1e4a313 --- /dev/null +++ b/cypress/fixtures/data_import.json @@ -0,0 +1,158 @@ +{ + "version": "2.0", + "data": { + "addressBook": { + "5": { + "0x61a0c717d18232711bC788F19C9Cd56a43cc8872": "test1", + "0x7724b234c9099C205F03b458944942bcEBA13408": "test2", + "0x6E45d69a383CECa3d54688e833Bd0e1388747e6B": "test3", + "0x10f999F150a2E7fd356Aa471bCBf0b75aA7b0e2A": "safe 1 goerli" + }, + "100": { + "0x17b34aEf1428A358bA2eA360a098b8A3BEb698C8": "safe 1 GNO", + "0x11A6B41322C57Bd0e56cEe06abB11A1E5c1FF1BB": "Safe 2 GNO", + "0xB8d760a90a5ed54D3c2b3EFC231277e99188642A": "main xdai safe", + "0x11B1D54B66e5e226D6f89069c21A569A22D98cfd": "trez", + "0x61a0c717d18232711bC788F19C9Cd56a43cc8872": "test1", + "0x8aEf2f5c3F17261F6F1C4dA058D022BE92776af8": "ow1", + "0x0D65139Da4B36a8A39BF1b63e950038D42231b2e": "ow 2" + }, + "137": { + "0xC680d44F526f4372693CAc21dcab255b77bc58F4": "Safe 1 Poly", + "0x61a0c717d18232711bC788F19C9Cd56a43cc8872": "Test1 Poly" + } + }, + "addedSafes": { + "5": { + "0x10f999F150a2E7fd356Aa471bCBf0b75aA7b0e2A": { + "owners": [ + { + "value": "0x8aEf2f5c3F17261F6F1C4dA058D022BE92776af8" + }, + { + "value": "0x61a0c717d18232711bC788F19C9Cd56a43cc8872" + } + ], + "threshold": 2, + "ethBalance": "0" + } + }, + "100": { + "0x17b34aEf1428A358bA2eA360a098b8A3BEb698C8": { + "owners": [ + { + "value": "0x11B1D54B66e5e226D6f89069c21A569A22D98cfd" + } + ], + "threshold": 1, + "ethBalance": "0.001000002" + }, + "0x11A6B41322C57Bd0e56cEe06abB11A1E5c1FF1BB": { + "owners": [ + { + "value": "0x7724b234c9099C205F03b458944942bcEBA13408" + }, + { + "value": "0x8aEf2f5c3F17261F6F1C4dA058D022BE92776af8" + }, + { + "value": "0x0D65139Da4B36a8A39BF1b63e950038D42231b2e" + } + ], + "threshold": 1, + "ethBalance": "0" + }, + "0xB8d760a90a5ed54D3c2b3EFC231277e99188642A": { + "owners": [ + { + "value": "0x11B1D54B66e5e226D6f89069c21A569A22D98cfd" + }, + { + "value": "0x457D3Fcb58401F9b98d83BC2fe7BF57FF57603AB" + }, + { + "value": "0x61a0c717d18232711bC788F19C9Cd56a43cc8872" + }, + { + "value": "0xFD71c1ABadBD37F60E4C8F208386dDFC4d2Bf01f" + }, + { + "value": "0x5aC255889882aCd3da2aA939679E3f3d4cea221e" + }, + { + "value": "0x474e5Ded6b5D078163BFB8F6dBa355C3aA5478C8" + }, + { + "value": "0x86a9F6704280Ac2b99aBD60ed74bF9cF899bd925" + }, + { + "value": "0x8aEf2f5c3F17261F6F1C4dA058D022BE92776af8" + }, + { + "value": "0x0D65139Da4B36a8A39BF1b63e950038D42231b2e" + } + ], + "threshold": 1, + "ethBalance": "0.92132507668989" + } + }, + "137": { + "0xC680d44F526f4372693CAc21dcab255b77bc58F4": { + "owners": [ + { + "value": "0x0D65139Da4B36a8A39BF1b63e950038D42231b2e" + }, + { + "value": "0x8aEf2f5c3F17261F6F1C4dA058D022BE92776af8" + } + ], + "threshold": 1, + "ethBalance": "0" + } + } + }, + "settings": { + "currency": "eur", + "tokenList": "TRUSTED", + "hiddenTokens": {}, + "shortName": { + "show": false, + "copy": false, + "qr": false + }, + "theme": { + "darkMode": true + }, + "env": { + "rpc": {}, + "tenderly": { + "url": "", + "accessToken": "" + } + }, + "signing": { + "onChainSigning": false + } + }, + "safeApps": { + "5": { + "pinned": [ + 36, + 24 + ] + }, + "100": { + "pinned": [ + 17, + 93 + ] + }, + "137": { + "pinned": [ + 71, + 17 + ] + } + } + } +} \ No newline at end of file diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 8b059500..75eae8fb 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -5,6 +5,18 @@ Cypress.Commands.add('connectE2EWallet', () => { }) }) +Cypress.Commands.add('useProdCGW', () => { + cy.on('window:before:load', (window) => { + window.localStorage.setItem('SAFE_v2__debugProdCgw', JSON.stringify(true)) + }) +}) + +Cypress.Commands.add('disableProdCGW', () => { + cy.on('window:before:load', (window) => { + window.localStorage.setItem('SAFE_v2__debugProdCgw', JSON.stringify(false)) + }) +}) + let LOCAL_STORAGE_MEMORY = {} Cypress.Commands.add('saveLocalStorageCache', () => { @@ -18,3 +30,49 @@ Cypress.Commands.add('restoreLocalStorageCache', () => { localStorage.setItem(key, LOCAL_STORAGE_MEMORY[key]) }) }) + +/** + * Wait for a thing by polling for it + * + * @param {(string|function)} item - A jQuery selector string or a function that returns a boolean + * @param {object} [options] - An options object + * @param {number} [options.timeout=200] - The time between tries in milliseconds + * @param {number} [options.tries=300] - The amount of times to try before failing + * + * @return {Promise} - A Cypress promise, more at https://docs.cypress.io/api/utilities/promise.html + */ +const waitForSelector = (item, options = {}) => { + if (typeof item !== 'string' && !(item instanceof Function)) { + throw new Error('Cypress plugin waitForSelector: The first parameter should be a string or a function') + } + + const defaultSettings = { + timeout: 200, + tries: 300, + } + const SETTINGS = { ...defaultSettings, ...options } + + const check = (item) => { + if (typeof item === 'string') { + return Cypress.$(item).length > 0 + } else { + return item() + } + } + + return new Cypress.Promise((resolve, reject) => { + let index = 0 + const interval = setInterval(() => { + if (check(item)) { + clearInterval(interval) + resolve() + } + if (index > SETTINGS.tries) { + reject() + } + index++ + }, SETTINGS.timeout) + }) +} + +Cypress.Commands.add('waitForSelector', waitForSelector) diff --git a/docs/eip-1271.md b/docs/eip-1271.md deleted file mode 100644 index 0478e1f5..00000000 --- a/docs/eip-1271.md +++ /dev/null @@ -1,260 +0,0 @@ -# EIP-1271 Off-Chain signatures - -_Note: The current state of the implementation is experimental and subject to change._ - -The Safe contracts and interface support off chain [EIP-1271](https://eips.ethereum.org/EIPS/eip-1271) signatures. -But due to Safe being a multisig wallet the flow is slightly different from simple EOA signatures. - -This doc explains with examples how to off-chain sign and verify messages. -All examples will be using [WalletConnect](https://walletconnect.com/) to connect to the Safe and [ethers](https://ethers.io). - -## Signing messages - -It is possible to sign [EIP-191](https://eips.ethereum.org/EIPS/eip-712) compliant messages as well as [EIP-712](https://eips.ethereum.org/EIPS/eip-191) typed data messages. - -### Enabling off-chain signing - -Before off-chain signing it was and still is possible to sign messages on-chain. Multiple dApps exist in production which rely on this as the default behavior. -Therefore to enable off-chain signing it is necessary to send a custom RPC call `safe_setSettings` and pass in - -```json -{ - "offChainSigning": true -} -``` - -to signal that the dApp wants to use off-chain signing. - -#### Example: Enable off-chain signing - -```ts -const enableOffChainSigning = async () => { - const result = await connector.sendCustomRequest({ - method: 'safe_setSettings', - params: [{ offChainSigning: true }], - }) - - if (result?.offChainSigning !== true) { - throw new Error('Off-chain signing could not be enabled.') - } -} -``` - -See also [troubleshooting](#off-chain-signing-could-not-be-enabled) - -### EIP 191 messages - -To sign a message we will have to call the `signMessage` function and pass in the message as hex string. - -As meeting the threshold of a Safe message can take quite long, the `signMessage` call will not wait for a message to be fully signed, but instead always return `0x` once the first signature was added. - -#### Example: Sign message - -```ts -import { hashMessage, hexlify, toUtf8Bytes } from 'ethers/lib/utils' - -const signMessage = async (message: string) => { - const hexMessage = hexlify(toUtf8Bytes(message)) - await connector.signMessage([safeAddress, hexMessage]) -} -``` - -After signing a message it will be available in the Safe's message list (Transactions -> Messages). - -### EIP 712 typed data - -To sign typed data we will have to call the `signTypedData` function and pass in the typed data object. - -As meeting the threshold of a Safe message can take quite long, the `signTypedData` call will not wait for a message to be fully signed, but instead always return `0x` once the first signature was added. - -
- -Example: Sign typed data - - -```ts -const getExampleData = () => { - return { - types: { - EIP712Domain: [ - { name: 'name', type: 'string' }, - { name: 'version', type: 'string' }, - { name: 'chainId', type: 'uint256' }, - { name: 'verifyingContract', type: 'address' }, - ], - Example: [{ name: 'content', type: 'string' }], - }, - primaryType: 'Example', - domain: { - name: 'EIP-1271 Example DApp', - version: '1.0', - chainId: 1, - verifyingContract: '0x123..456', - }, - message: { - content: 'Hello World!', - }, - } -} - -const signTypedData = async () => { - const typedData = getExampleData() - await connector.signTypedData([safeAddress, JSON.stringify(typedData)]) -} -``` - -
- -After signing, the message will be available in the Safe's message list (Transactions -> Messages). - -## Verifying signatures - -To verify a signature we first have to retrieve the signature from the [safe-transaction-service](https://github.com/safe-global/safe-transaction-service). - -_Note: currently this feature is only available on [staging](https://safe-transaction-goerli.staging.5afe.dev/)_ - -Therefore we have to generate a hash of the `message` or `typedData` using ethers' `hashMessage(message)` or `_TypedDataEncoder.hash(domain, types, message)` and then compute the `Safe message hash` by calling `getMessageHash(messageHash)` on the Safe contract. - -
- -Example: get Safe message hash - - -```ts -const getSafeInterface = () => { - const SAFE_ABI = [ - 'function getThreshold() public view returns (uint256)', - 'function getMessageHash(bytes memory message) public view returns (bytes32)', - 'function isValidSignature(bytes calldata _data, bytes calldata _signature) public view returns (bytes4)', - ] - - return new Interface(SAFE_ABI) -} - -const getSafeMessageHash = async (connector: WalletConnect, safeAddress: string, messageHash: string) => { - // https://github.com/safe-global/safe-contracts/blob/main/contracts/handler/CompatibilityFallbackHandler.sol#L43 - const getMessageHash = getSafeInterface().encodeFunctionData('getMessageHash', [messageHash]) - - return connector.sendCustomRequest({ - method: 'eth_call', - params: [{ to: safeAddress, data: getMessageHash }], - }) -} -``` - -
- -Now we can query the state of the message from the tx-service's endpoint for messages: - -`https://safe-transaction-goerli.staging.5afe.dev/api/messages/` - -
- - Example: Load message from tx service - - -```ts -const fetchMessage = async (safeMessageHash: string): Promise => { - const safeMessage = await fetch( - `https://safe-transaction-goerli.staging.5afe.dev/api/v1/messages/${safeMessageHash}`, - { - headers: { 'Content-Type': 'application/json' }, - }, - ).then((res) => { - if (!res.ok) { - return Promise.reject('Invalid response when fetching SafeMessage') - } - return res.json() as Promise - }) - - return safeMessage -} -``` - -
- -A Safe message has the following format: - -```ts -{ - "messageHash": string, - "status": string, - "logoUri": string | null, - "name": string | null, - "message": string | EIP712TypedData, - "creationTimestamp": number, - "modifiedTimestamp": number, - "confirmationsSubmitted": number, - "confirmationsRequired": number, - "proposedBy": { "value": string }, - "confirmations": [ - { - "owner": { "value": string }, - "signature": string - } - ], - "preparedSignature": string | null -} -``` - -A fully signed message will have the status `CONFIRMED`, `confirmationsSubmitted >= confirmationsRequired` and a `preparedSignature !== null`. - -The signature of the message will be returned in the `preparedSignature` field. - -Now we can use this signature to verify that it is correct on-chain using EIP-1271's `isValidSignature(hash, signature)` function. This function will return the `MAGIC VALUE BYTES 0x20c13b0b` if the `signature` is correct for the `messageHash`. - -_Note: A common pitfall is to pass the `safeMessageHash` to the `isValidSignature` call which is not correct. It needs to be the hash of the original message._ - -
- -Example: verify signature - - -```ts -const MAGIC_VALUE_BYTES = '0x20c13b0b' - -const isValidSignature = async ( - connector: WalletConnect, - safeAddress: string, - messageHash: string, - signature: string, -) => { - // https://github.com/safe-global/safe-contracts/blob/main/contracts/handler/CompatibilityFallbackHandler.sol#L28 - const isValidSignatureData = getSafeInterface().encodeFunctionData('isValidSignature', [messageHash, signature]) - - const isValidSignature = (await connector.sendCustomRequest({ - method: 'eth_call', - params: [{ to: safeAddress, data: isValidSignatureData }], - })) as string - - return isValidSignature?.slice(0, 10).toLowerCase() === MAGIC_VALUE_BYTES -} -``` - -
- -Following these steps it is now possible to submit messages for off-chain signing and poll the tx service until a message is fully signed. - -### Example dApps - -- [Small test dApp](https://github.com/5afe/eip-1271-dapp) - -## Troubleshooting - -### Off-chain signing could not be enabled - -If the `safe_setSettings` call does not return the expected result, off-chain signing could not be enabled for that Safe. This could have various reasons: - -- The connected Safe does not have a _fallback handler_ set. This can happen if Safes were not created through the official interface such as a CLI or third party interface. -- The Safe version is not compatible: Off-chain signing is only available for Safes with version `>1.0.0` - -### Confusion of messageHash and safeMessageHash - -A common pitfall is that the `message`, `messageHash` and `safeMessageHash` gets mixed up. -Currently when integrating a dApp, the `safeMessageHash` only needs to be used to fetch data from our transaction-service. -In all other cases (verifying signatures, signing a message) the `messageHash` or the `message` is correct. - -## Restrictions - -- Only Safes with version `>1.0.0` are supported -- Currently this is only implemented on staging services diff --git a/docs/environments.md b/docs/environments.md index 3c091f2c..0f43c8d4 100644 --- a/docs/environments.md +++ b/docs/environments.md @@ -5,9 +5,9 @@ We have several environments where the app can be deployed: |Env|URL|Purpose|How it's deployed|Backend env| |---|---|---|---|---| |local|http://localhost:3000/app|local development|`yarn start`|staging| -|PRs |`https://--webcore.review-web-core.5afe.dev/`|peer review & feature QA|for all PRs on push|staging| -|dev |https://safe-web-core.dev.5afe.dev/|preview of all WIP features|on push to the `dev` branch|staging| -|staging|https://safe-web-core.staging.5afe.dev/|preview of features before a release|on push to `main`|staging| +|PRs |`https://--walletweb.review-wallet-web.5afe.dev/`|peer review & feature QA|for all PRs on push|staging| +|dev |https://safe-wallet-web.dev.5afe.dev/|preview of all WIP features|on push to the `dev` branch|staging| +|staging|https://safe-wallet-web.staging.5afe.dev/|preview of features before a release|on push to `main`|staging| |production|https://app.safe.global/|live app|deployed by DevOps (see the [Release Procedure](release-procedure.md))|**production**| ## Lifecycle of a feature @@ -22,11 +22,11 @@ After a feature enters the development cycle (i.e. is in a sprint), it goes thro 5. Once QA gives a green light, the branch is merged to the `dev` branch ### Release -1. All merged branches sit on `dev`, which is occasionally reviewed on the [dev site](https://safe-web-core.dev.5afe.dev/). +1. All merged branches sit on `dev`, which is occasionally reviewed on the [dev site](https://safe-wallet-web.dev.5afe.dev/). 2. In case some regression is noticed, it's fixed on dev. 3. Once a sufficient amount of features are ready for a release (at least once in a sprint), a release branch is made (normally from the HEAD of `dev`) and a PR to `main` is created. 4. QA does regression testing on the release branch. The backend APIs are pointing to production on this branch so that all chains can be tested. -5. Once QA passes, the branch is merged to `main` and is automatically deployed to the [staging site](https://safe-web-core.staging.5afe.dev/). +5. Once QA passes, the branch is merged to `main` and is automatically deployed to the [staging site](https://safe-wallet-web.staging.5afe.dev/). 6. It sits on staging for a short while where QA and the release manager briefly do a final check before going live. 7. DevOps are requested to deploy the code from `main` to the production env. 8. Once it's done, brief sanity checks are done on the [production site](https://app.safe.global/). diff --git a/jest.setup.js b/jest.setup.js index 6169ac7d..207d62ec 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -9,18 +9,10 @@ import 'whatwg-fetch' jest.mock('@web3-onboard/coinbase', () => jest.fn()) jest.mock('@web3-onboard/injected-wallets', () => ({ ProviderLabel: { MetaMask: 'MetaMask' } })) jest.mock('@web3-onboard/keystone/dist/index', () => jest.fn()) -jest.mock('@web3-onboard/ledger', () => jest.fn()) +jest.mock('@web3-onboard/ledger/dist/index', () => jest.fn()) jest.mock('@web3-onboard/trezor', () => jest.fn()) jest.mock('@web3-onboard/walletconnect', () => jest.fn()) -jest.mock('@web3-onboard/tallyho', () => jest.fn()) - -jest.mock('@web3-onboard/injected-wallets/dist/icons/metamask', () => '') -jest.mock('@web3-onboard/coinbase/dist/icon', () => '') -jest.mock('@web3-onboard/keystone/dist/icon', () => '') -jest.mock('@web3-onboard/walletconnect/dist/icon', () => '') -jest.mock('@web3-onboard/trezor/dist/icon', () => '') -jest.mock('@web3-onboard/ledger/dist/icon', () => '') -jest.mock('@web3-onboard/tallyho/dist/icon', () => '') +jest.mock('@web3-onboard/taho', () => jest.fn()) const mockOnboardState = { chains: [], diff --git a/package.json b/package.json index 001a5054..f4b040d1 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { - "name": "web-core", - "homepage": "https://github.com/safe-global/web-core", + "name": "safe-wallet-web", + "homepage": "https://github.com/safe-global/safe-wallet-web", "license": "GPL-3.0", "type": "module", - "version": "1.7.1", + "version": "1.14.0", "scripts": { "dev": "next dev", - "start": "next start", + "start": "next dev", "build": "next build", "export": "next export", "lint": "tsc && next lint", @@ -14,19 +14,20 @@ "prettier": "prettier -w \"{src,cypress,mocks,scripts}/**/*.{ts,tsx,css,js}\"", "fix": "yarn lint:fix && ts-prune && yarn prettier", "test": "cross-env TZ=CET DEBUG_PRINT_LIMIT=30000 jest", - "test:ci": "yarn test --ci --coverage --json --watchAll=false --testLocationInResults --outputFile=jest.results.json", + "test:ci": "yarn test --ci --silent --coverage --json --watchAll=false --testLocationInResults --outputFile=jest.results.json", "test:coverage": "yarn test --coverage --watchAll=false", "cmp": "./scripts/cmp.sh", "routes": "node scripts/generate-routes.js > src/config/routes.ts && prettier -w src/config/routes.ts && cat src/config/routes.ts", "css-vars": "ts-node-esm ./scripts/css-vars.ts > ./src/styles/vars.css && prettier -w src/styles/vars.css", - "generate-types": "typechain --target ethers-v5 --out-dir src/types/contracts ./node_modules/@gnosis.pm/safe-deployments/dist/assets/**/*.json ./node_modules/@gnosis.pm/safe-modules-deployments/dist/assets/**/*.json ./node_modules/@openzeppelin/contracts/build/contracts/ERC20.json", + "generate-types": "typechain --target ethers-v5 --out-dir src/types/contracts ./node_modules/@safe-global/safe-deployments/dist/assets/**/*.json ./node_modules/@safe-global/safe-modules-deployments/dist/assets/**/*.json ./node_modules/@openzeppelin/contracts/build/contracts/ERC20.json ./node_modules/@openzeppelin/contracts/build/contracts/ERC721.json", "postinstall": "patch-package && yarn generate-types && yarn css-vars", "analyze": "cross-env ANALYZE=true yarn build", - "cypress:open": "cross-env TZ=UTC cypress open", + "cypress:open": "cross-env TZ=UTC cypress open --e2e", + "cypress:canary": "cross-env TZ=UTC cypress open --e2e -b chrome:canary", "cypress:run": "cypress run", "cypress:ci": "yarn cypress:run --config baseUrl=http://localhost:8080 --spec cypress/e2e/smoke/*.cy.js", - "static-serve": "yarn build && yarn export && yarn serve", - "serve": "npx -y http-server out" + "serve": "npx -y serve out -p ${REVERSE_PROXY_UI_PORT:=8080}", + "static-serve": "yarn build && yarn export && yarn serve" }, "pre-commit": [ "lint" @@ -37,30 +38,29 @@ "@emotion/react": "^11.10.0", "@emotion/server": "^11.10.0", "@emotion/styled": "^11.10.0", - "@gnosis.pm/safe-deployments": "^1.18.0", - "@gnosis.pm/safe-modules-deployments": "^1.0.0", "@mui/icons-material": "^5.8.4", "@mui/material": "^5.11.10", "@mui/x-date-pickers": "^5.0.12", - "@reduxjs/toolkit": "^1.8.2", - "@safe-global/safe-apps-sdk": "^7.10.1", - "@safe-global/safe-core-sdk": "^3.3.2", - "@safe-global/safe-core-sdk-utils": "^1.7.2", - "@safe-global/safe-deployments": "^1.20.2", - "@safe-global/safe-ethers-lib": "^1.9.2", - "@safe-global/safe-gateway-typescript-sdk": "^3.7.0", - "@safe-global/safe-react-components": "^2.0.2", + "@reduxjs/toolkit": "^1.9.5", + "@safe-global/safe-apps-sdk": "7.11.0", + "@safe-global/safe-core-sdk": "^3.3.4", + "@safe-global/safe-core-sdk-utils": "^1.7.4", + "@safe-global/safe-deployments": "^1.25.0", + "@safe-global/safe-ethers-lib": "^1.9.4", + "@safe-global/safe-gateway-typescript-sdk": "^3.7.3", + "@safe-global/safe-modules-deployments": "^1.0.0", + "@safe-global/safe-react-components": "^2.0.5", "@sentry/react": "^7.28.1", "@sentry/tracing": "^7.28.1", "@truffle/hdwallet-provider": "^2.1.4", - "@web3-onboard/coinbase": "^2.1.3", - "@web3-onboard/core": "2.8.5", - "@web3-onboard/injected-wallets": "^2.3.0", - "@web3-onboard/keystone": "^2.3.2", - "@web3-onboard/ledger": "^2.3.2", - "@web3-onboard/tallyho": "^2.0.1", - "@web3-onboard/trezor": "^2.3.2", - "@web3-onboard/walletconnect": "^2.1.3", + "@web3-onboard/coinbase": "^2.2.4", + "@web3-onboard/core": "2.20.4", + "@web3-onboard/injected-wallets": "^2.10.0", + "@web3-onboard/keystone": "^2.3.7", + "@web3-onboard/ledger": "2.3.2", + "@web3-onboard/taho": "^2.0.5", + "@web3-onboard/trezor": "^2.4.2", + "@web3-onboard/walletconnect": "2.4.0", "classnames": "^2.3.1", "date-fns": "^2.29.2", "ethereum-blockies-base64": "^1.0.2", @@ -72,8 +72,6 @@ "next": "12.2.0", "next-pwa": "^5.6.0", "papaparse": "^5.3.2", - "patch-package": "^6.5.1", - "postinstall-postinstall": "^2.1.0", "qrcode.react": "^3.1.0", "react": "18.2.0", "react-dom": "18.2.0", @@ -82,13 +80,13 @@ "react-hook-form": "7.41.1", "react-papaparse": "^4.0.2", "react-qr-reader": "2.2.1", - "react-redux": "^8.0.2", - "semver": "^7.3.7" + "react-redux": "^8.0.5", + "semver": "^7.5.2" }, "devDependencies": { "@next/bundle-analyzer": "^13.1.1", - "@openzeppelin/contracts": "^4.8.1", - "@safe-global/safe-core-sdk-types": "^1.9.0", + "@openzeppelin/contracts": "^4.9.2", + "@safe-global/safe-core-sdk-types": "^1.9.1", "@sentry/types": "^7.28.1", "@svgr/webpack": "^6.3.1", "@testing-library/cypress": "^8.0.7", @@ -117,6 +115,8 @@ "eslint-plugin-unused-imports": "^2.0.0", "jest": "^28.1.2", "jest-environment-jsdom": "^28.1.2", + "patch-package": "^7.0.2", + "postinstall-postinstall": "^2.1.0", "pre-commit": "^1.2.2", "prettier": "^2.7.0", "ts-node": "^10.8.2", diff --git a/patches/@gnosis.pm+safe-modules-deployments+1.0.0.patch b/patches/@gnosis.pm+safe-modules-deployments+1.0.0.patch deleted file mode 100644 index 0e1e8441..00000000 --- a/patches/@gnosis.pm+safe-modules-deployments+1.0.0.patch +++ /dev/null @@ -1,26 +0,0 @@ -diff --git a/node_modules/@gnosis.pm/safe-modules-deployments/dist/assets/allowance-module/v0.1.0/allowance-module.json b/node_modules/@gnosis.pm/safe-modules-deployments/dist/assets/allowance-module/v0.1.0/allowance-module.json -index 0f19f4e..3d0ef48 100644 ---- a/node_modules/@gnosis.pm/safe-modules-deployments/dist/assets/allowance-module/v0.1.0/allowance-module.json -+++ b/node_modules/@gnosis.pm/safe-modules-deployments/dist/assets/allowance-module/v0.1.0/allowance-module.json -@@ -11,6 +11,8 @@ - "100": "0xCFbFaC74C26F8647cBDb8c5caf80BB5b32E43134", - "137": "0x1Fb403834C911eB98d56E74F5182b0d64C3b3b4D", - "246": "0xCFbFaC74C26F8647cBDb8c5caf80BB5b32E43134", -+ "9000": "0x386bc7cD21514f978a802d0818eA652Ee9346dAA", -+ "9001": "0x386bc7cD21514f978a802d0818eA652Ee9346dAA", - "43114": "0x1Fb403834C911eB98d56E74F5182b0d64C3b3b4D", - "73799": "0xCFbFaC74C26F8647cBDb8c5caf80BB5b32E43134" - }, -diff --git a/node_modules/@gnosis.pm/safe-modules-deployments/src/assets/allowance-module/v0.1.0/allowance-module.json b/node_modules/@gnosis.pm/safe-modules-deployments/src/assets/allowance-module/v0.1.0/allowance-module.json -index 700f9b0..3e599f5 100644 ---- a/node_modules/@gnosis.pm/safe-modules-deployments/src/assets/allowance-module/v0.1.0/allowance-module.json -+++ b/node_modules/@gnosis.pm/safe-modules-deployments/src/assets/allowance-module/v0.1.0/allowance-module.json -@@ -11,6 +11,8 @@ - "100": "0xCFbFaC74C26F8647cBDb8c5caf80BB5b32E43134", - "137": "0x1Fb403834C911eB98d56E74F5182b0d64C3b3b4D", - "246": "0xCFbFaC74C26F8647cBDb8c5caf80BB5b32E43134", -+ "9000": "0x386bc7cD21514f978a802d0818eA652Ee9346dAA", -+ "9001": "0x386bc7cD21514f978a802d0818eA652Ee9346dAA", - "43114": "0x1Fb403834C911eB98d56E74F5182b0d64C3b3b4D", - "73799": "0xCFbFaC74C26F8647cBDb8c5caf80BB5b32E43134" - }, diff --git a/patches/@safe-global+safe-modules-deployments+1.1.0.patch b/patches/@safe-global+safe-modules-deployments+1.1.0.patch new file mode 100644 index 00000000..71bc3a2f --- /dev/null +++ b/patches/@safe-global+safe-modules-deployments+1.1.0.patch @@ -0,0 +1,26 @@ +diff --git a/node_modules/@safe-global/safe-modules-deployments/dist/assets/allowance-module/v0.1.0/allowance-module.json b/node_modules/@safe-global/safe-modules-deployments/dist/assets/allowance-module/v0.1.0/allowance-module.json +index be46b64..e1c24a0 100644 +--- a/node_modules/@safe-global/safe-modules-deployments/dist/assets/allowance-module/v0.1.0/allowance-module.json ++++ b/node_modules/@safe-global/safe-modules-deployments/dist/assets/allowance-module/v0.1.0/allowance-module.json +@@ -11,6 +11,8 @@ + "100": "0xCFbFaC74C26F8647cBDb8c5caf80BB5b32E43134", + "137": "0x1Fb403834C911eB98d56E74F5182b0d64C3b3b4D", + "246": "0xCFbFaC74C26F8647cBDb8c5caf80BB5b32E43134", ++ "9000": "0x386bc7cD21514f978a802d0818eA652Ee9346dAA", ++ "9001": "0x386bc7cD21514f978a802d0818eA652Ee9346dAA", + "42220": "0xCFbFaC74C26F8647cBDb8c5caf80BB5b32E43134", + "43114": "0x1Fb403834C911eB98d56E74F5182b0d64C3b3b4D", + "73799": "0xCFbFaC74C26F8647cBDb8c5caf80BB5b32E43134" +diff --git a/node_modules/@safe-global/safe-modules-deployments/src/assets/allowance-module/v0.1.0/allowance-module.json b/node_modules/@safe-global/safe-modules-deployments/src/assets/allowance-module/v0.1.0/allowance-module.json +index e4126fd..3e8b1e2 100644 +--- a/node_modules/@safe-global/safe-modules-deployments/src/assets/allowance-module/v0.1.0/allowance-module.json ++++ b/node_modules/@safe-global/safe-modules-deployments/src/assets/allowance-module/v0.1.0/allowance-module.json +@@ -11,6 +11,8 @@ + "100": "0xCFbFaC74C26F8647cBDb8c5caf80BB5b32E43134", + "137": "0x1Fb403834C911eB98d56E74F5182b0d64C3b3b4D", + "246": "0xCFbFaC74C26F8647cBDb8c5caf80BB5b32E43134", ++ "9000": "0x386bc7cD21514f978a802d0818eA652Ee9346dAA", ++ "9001": "0x386bc7cD21514f978a802d0818eA652Ee9346dAA", + "42220": "0xCFbFaC74C26F8647cBDb8c5caf80BB5b32E43134", + "43114": "0x1Fb403834C911eB98d56E74F5182b0d64C3b3b4D", + "73799": "0xCFbFaC74C26F8647cBDb8c5caf80BB5b32E43134" diff --git a/public/favicons/favicon-dot.ico b/public/favicons/favicon-dot.ico new file mode 100644 index 00000000..b6eff1b6 Binary files /dev/null and b/public/favicons/favicon-dot.ico differ diff --git a/public/images/address-book/export.svg b/public/images/common/export.svg similarity index 100% rename from public/images/address-book/export.svg rename to public/images/common/export.svg diff --git a/public/images/common/gas-station.svg b/public/images/common/gas-station.svg new file mode 100644 index 00000000..af6b263b --- /dev/null +++ b/public/images/common/gas-station.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/common/gnosis-chain-logo.png b/public/images/common/gnosis-chain-logo.png new file mode 100644 index 00000000..9decbe63 Binary files /dev/null and b/public/images/common/gnosis-chain-logo.png differ diff --git a/public/images/address-book/import.svg b/public/images/common/import.svg similarity index 100% rename from public/images/address-book/import.svg rename to public/images/common/import.svg diff --git a/public/images/common/nft-zerion.svg b/public/images/common/nft-zerion.svg new file mode 100644 index 00000000..0850d091 --- /dev/null +++ b/public/images/common/nft-zerion.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/public/images/common/relayer.svg b/public/images/common/relayer.svg new file mode 100644 index 00000000..ab93c6e5 --- /dev/null +++ b/public/images/common/relayer.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/common/safe-token.svg b/public/images/common/safe-token.svg new file mode 100644 index 00000000..40b316f2 --- /dev/null +++ b/public/images/common/safe-token.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/public/images/notifications/alert.svg b/public/images/notifications/alert.svg new file mode 100644 index 00000000..4e19cc12 --- /dev/null +++ b/public/images/notifications/alert.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/transactions/redefine-dark-mode.svg b/public/images/transactions/redefine-dark-mode.svg new file mode 100644 index 00000000..97ced929 --- /dev/null +++ b/public/images/transactions/redefine-dark-mode.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/transactions/redefine.svg b/public/images/transactions/redefine.svg new file mode 100644 index 00000000..449dd63f --- /dev/null +++ b/public/images/transactions/redefine.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/address-book/AddressBookHeader/index.tsx b/src/components/address-book/AddressBookHeader/index.tsx index ec6fba62..ba2baed1 100644 --- a/src/components/address-book/AddressBookHeader/index.tsx +++ b/src/components/address-book/AddressBookHeader/index.tsx @@ -10,8 +10,8 @@ import PageHeader from '@/components/common/PageHeader' import { ModalType } from '../AddressBookTable' import { useAppSelector } from '@/store' import { selectAllAddressBooks } from '@/store/addressBookSlice' -import ImportIcon from '@/public/images/address-book/import.svg' -import ExportIcon from '@/public/images/address-book/export.svg' +import ImportIcon from '@/public/images/common/import.svg' +import ExportIcon from '@/public/images/common/export.svg' import AddCircleIcon from '@/public/images/common/add-outlined.svg' const HeaderButton = ({ diff --git a/src/components/address-book/AddressBookTable/index.tsx b/src/components/address-book/AddressBookTable/index.tsx index 410ac66d..ad38e2f5 100644 --- a/src/components/address-book/AddressBookTable/index.tsx +++ b/src/components/address-book/AddressBookTable/index.tsx @@ -11,7 +11,6 @@ import Button from '@mui/material/Button' import IconButton from '@mui/material/IconButton' import Tooltip from '@mui/material/Tooltip' import RemoveDialog from '@/components/address-book/RemoveDialog' -import useIsGranted from '@/hooks/useIsGranted' import EthHashInfo from '@/components/common/EthHashInfo' import AddressBookHeader from '../AddressBookHeader' import useAddressBook from '@/hooks/useAddressBook' @@ -24,6 +23,7 @@ import { useCurrentChain } from '@/hooks/useChains' import tableCss from '@/components/common/EnhancedTable/styles.module.css' import TokenTransferModal from '@/components/tx/modals/TokenTransferModal' import { SendAssetsField } from '@/components/tx/modals/TokenTransferModal/SendAssetsForm' +import CheckWallet from '@/components/common/CheckWallet' const headCells = [ { id: 'name', label: 'Name' }, @@ -47,7 +47,6 @@ const defaultOpen = { const AddressBookTable = () => { const chain = useCurrentChain() - const isGranted = useIsGranted() const [open, setOpen] = useState(defaultOpen) const [searchQuery, setSearchQuery] = useState('') const [defaultValues, setDefaultValues] = useState(undefined) @@ -111,13 +110,21 @@ const AddressBookTable = () => { - {isGranted && ( - - - - )} + + {(isOk) => ( + + + + )} + ), }, diff --git a/src/components/address-book/ExportDialog/index.tsx b/src/components/address-book/ExportDialog/index.tsx index 7d0f5909..9407e320 100644 --- a/src/components/address-book/ExportDialog/index.tsx +++ b/src/components/address-book/ExportDialog/index.tsx @@ -11,6 +11,7 @@ import { type AddressBookState, selectAllAddressBooks } from '@/store/addressBoo import { useAppSelector } from '@/store' import { trackEvent, ADDRESS_BOOK_EVENTS } from '@/services/analytics' import ExternalLink from '@/components/common/ExternalLink' +import { HelpCenterArticle } from '@/config/constants' const COL_1 = 'address' const COL_2 = 'name' @@ -66,7 +67,7 @@ const ExportDialog = ({ handleClose }: { handleClose: () => void }): ReactElemen Learn about the address book import and export diff --git a/src/components/address-book/ImportDialog/__tests__/validation.test.ts b/src/components/address-book/ImportDialog/__tests__/validation.test.ts index 2078c182..5410f268 100644 --- a/src/components/address-book/ImportDialog/__tests__/validation.test.ts +++ b/src/components/address-book/ImportDialog/__tests__/validation.test.ts @@ -3,7 +3,6 @@ import { abCsvReaderValidator, abOnUploadValidator, hasValidAbEntryAddresses, - hasValidAbEntryChainIds, hasValidAbHeader, hasValidAbNames, } from '../validation' @@ -90,33 +89,6 @@ describe('Address book import validation', () => { }) }) - describe('hasValidAbEntryChainIds', () => { - it('should return true if all entries have valid chainIds', () => { - const entries = [ - ['0xAb5e3288640396C3988af5a820510682f3C58adF', 'name', '1'], - ['0x1F2504De05f5167650bE5B28c472601Be434b60A', 'name1', '4'], - ['0x1F2504De05f5167650bE5B28c472601Be434b60A', 'name1', ' 100'], - ] - - expect(hasValidAbEntryChainIds(entries)).toBe(true) - }) - - it('should return false if any entry has invalid chainId', () => { - const entries1 = [ - ['0xAb5e3288640396C3988af5a820510682f3C58adF', 'name', '1234523453245324634567543'], - ['0x1F2504De05f5167650bE5B28c472601Be434b60A', 'name1', ''], - ] - const entries2 = [ - ['0xAb5e3288640396C3988af5a820510682f3C58adF', 'name', ' 1 0 0 '], - ['0x1F2504De05f5167650bE5B28c472601Be434b60A', 'name1', '4', 'extra'], - ] - const entries3 = [['0xAb5e3288640396C3988af5a820510682f3C58adF'], []] - - expect(hasValidAbEntryChainIds(entries1)).toBe(false) - expect(hasValidAbEntryChainIds(entries2)).toBe(false) - expect(hasValidAbEntryChainIds(entries3)).toBe(false) - }) - }) describe('abOnUploadValidator', () => { it('should return undefined if result is valid', () => { const result = { @@ -199,18 +171,5 @@ describe('Address book import validation', () => { expect(abOnUploadValidator(result)).toBe('Address book contains an invalid name on row 2') }) - - it('should return an error if some entries have invalid chain IDs', () => { - const result = { - data: [ - ['address', 'name', 'chainId'], - ['0xAb5e3288640396C3988af5a820510682f3C58adF', 'name', '2394857230948572034598723049587230495872304958704'], - ], - errors: [], - meta: {} as ParseMeta, - } as ParseResult - - expect(abOnUploadValidator(result)).toBe('Address book contains an invalid chain ID on row 2') - }) }) }) diff --git a/src/components/address-book/ImportDialog/index.tsx b/src/components/address-book/ImportDialog/index.tsx index 6a80a7c9..9f35cf89 100644 --- a/src/components/address-book/ImportDialog/index.tsx +++ b/src/components/address-book/ImportDialog/index.tsx @@ -17,6 +17,7 @@ import ErrorMessage from '@/components/tx/ErrorMessage' import { Errors, logError } from '@/services/exceptions' import FileUpload, { FileTypes, type FileInfo } from '@/components/common/FileUpload' import ExternalLink from '@/components/common/ExternalLink' +import { HelpCenterArticle } from '@/config/constants' type AddressBookCSVRow = ['address', 'name', 'chainId'] @@ -151,10 +152,10 @@ const ImportDialog = ({ handleClose }: { handleClose: () => void }): ReactElemen {error && {error}} - Only CSV files exported from a Safe can be imported. + Only CSV files exported from a {'Evmos Safe'} can be imported.
Learn about the address book import and export diff --git a/src/components/address-book/ImportDialog/validation.ts b/src/components/address-book/ImportDialog/validation.ts index a8c75528..772f35e4 100644 --- a/src/components/address-book/ImportDialog/validation.ts +++ b/src/components/address-book/ImportDialog/validation.ts @@ -1,8 +1,8 @@ import type { ParseResult } from 'papaparse' -import { validateAddress, validateChainId } from '@/utils/validation' +import { validateAddress } from '@/utils/validation' -export const abCsvReaderValidator = ({ type, size }: File): string[] | undefined => { +export const abCsvReaderValidator = ({ size }: File): string[] | undefined => { if (size > 1_000_000) { return ['Address book cannot be larger than 1MB'] } @@ -20,16 +20,6 @@ export const hasValidAbNames = (entries: string[][]) => { return entries.every((entry) => entry.length >= 2 && !!entry[1]) } -export const hasValidAbEntryChainIds = (entries: string[][]) => { - return entries.every((entry) => { - if (entry.length < 3) { - return false - } - const chainId = entry[2].trim() - return !validateChainId(chainId) - }) -} - export const abOnUploadValidator = ({ data, errors }: ParseResult): string | undefined => { const [header, ...entries] = data @@ -66,10 +56,4 @@ export const abOnUploadValidator = ({ data, errors }: ParseResult): st const i = entries.findIndex((entry) => (entry.length >= 2 ? !entry[1] : true)) return `Address book contains an invalid name on row ${i + 2}` } - - // An entry has invalid chainId - if (!hasValidAbEntryChainIds(entries)) { - const i = entries.findIndex((entry) => (entry.length >= 3 ? validateChainId(entry[2]) : true)) - return `Address book contains an invalid chain ID on row ${i + 2}` - } } diff --git a/src/components/balances/AssetsHeader/index.tsx b/src/components/balances/AssetsHeader/index.tsx index 66278b13..6709eb94 100644 --- a/src/components/balances/AssetsHeader/index.tsx +++ b/src/components/balances/AssetsHeader/index.tsx @@ -1,21 +1,22 @@ -import { Box } from '@mui/material' import type { ReactElement, ReactNode } from 'react' import NavTabs from '@/components/common/NavTabs' import PageHeader from '@/components/common/PageHeader' import { balancesNavItems } from '@/components/sidebar/SidebarNavigation/config' +import css from '@/components/common/PageHeader/styles.module.css' + const AssetsHeader = ({ children }: { children?: ReactNode }): ReactElement => { return ( - +
+
- {children} - - +
+ {children &&
{children}
} +
} /> ) diff --git a/src/components/balances/AssetsTable/index.test.tsx b/src/components/balances/AssetsTable/index.test.tsx index 2a590bf7..759db503 100644 --- a/src/components/balances/AssetsTable/index.test.tsx +++ b/src/components/balances/AssetsTable/index.test.tsx @@ -105,6 +105,10 @@ describe('AssetsTable', () => { }, rpc: {}, }, + signing: { + onChainSigning: false, + }, + transactionExecution: true, }, }, }) @@ -207,6 +211,10 @@ describe('AssetsTable', () => { }, rpc: {}, }, + signing: { + onChainSigning: false, + }, + transactionExecution: true, }, }, }) @@ -305,6 +313,10 @@ describe('AssetsTable', () => { }, rpc: {}, }, + signing: { + onChainSigning: false, + }, + transactionExecution: true, }, }, }) @@ -400,6 +412,10 @@ describe('AssetsTable', () => { }, rpc: {}, }, + signing: { + onChainSigning: false, + }, + transactionExecution: true, }, }, }) diff --git a/src/components/balances/AssetsTable/index.tsx b/src/components/balances/AssetsTable/index.tsx index 449a4152..e4bc6a2d 100644 --- a/src/components/balances/AssetsTable/index.tsx +++ b/src/components/balances/AssetsTable/index.tsx @@ -9,7 +9,6 @@ import TokenIcon from '@/components/common/TokenIcon' import EnhancedTable, { type EnhancedTableProps } from '@/components/common/EnhancedTable' import TokenExplorerLink from '@/components/common/TokenExplorerLink' import TokenTransferModal from '@/components/tx/modals/TokenTransferModal' -import useIsGranted from '@/hooks/useIsGranted' import Track from '@/components/common/Track' import { ASSETS_EVENTS } from '@/services/analytics/events/assets' import InfoIcon from '@/public/images/notifications/info.svg' @@ -18,6 +17,8 @@ import TokenMenu from '../TokenMenu' import useBalances from '@/hooks/useBalances' import useHiddenTokens from '@/hooks/useHiddenTokens' import { useHideAssets } from './useHideAssets' +import CheckWallet from '@/components/common/CheckWallet' +import useSpendingLimit from '@/hooks/useSpendingLimit' const skeletonCells: EnhancedTableProps['rows'][0]['cells'] = { asset: { @@ -84,6 +85,34 @@ const headCells = [ }, ] +const SendButton = ({ + tokenInfo, + onClick, +}: { + tokenInfo: TokenInfo + onClick: (tokenAddress: string) => void +}): ReactElement => { + const spendingLimit = useSpendingLimit(tokenInfo) + + return ( + + {(isOk) => ( + + + + )} + + ) +} + const AssetsTable = ({ showHiddenAssets, setShowHiddenAssets, @@ -92,7 +121,6 @@ const AssetsTable = ({ setShowHiddenAssets: (hidden: boolean) => void }): ReactElement => { const [selectedAsset, setSelectedAsset] = useState() - const isGranted = useIsGranted() const hiddenAssets = useHiddenTokens() const { balances, loading } = useBalances() @@ -110,8 +138,6 @@ const AssetsTable = ({ const selectedAssetCount = visibleAssets?.filter((item) => isAssetSelected(item.tokenInfo.address)).length || 0 - const shouldHideSend = !isGranted - const rows = loading ? skeletonRows : (visibleAssets || []).map((item) => { @@ -177,19 +203,8 @@ const AssetsTable = ({ content: ( <> - {!shouldHideSend && ( - - - - )} + + {showHiddenAssets ? ( toggleAsset(item.tokenInfo.address)} /> ) : ( diff --git a/src/components/balances/HiddenTokenButton/index.test.tsx b/src/components/balances/HiddenTokenButton/index.test.tsx index 5bfb9447..2df2e63f 100644 --- a/src/components/balances/HiddenTokenButton/index.test.tsx +++ b/src/components/balances/HiddenTokenButton/index.test.tsx @@ -83,6 +83,10 @@ describe('HiddenTokenToggle', () => { }, rpc: {}, }, + signing: { + onChainSigning: false, + }, + transactionExecution: true, }, }, }) diff --git a/src/components/balances/HiddenTokenButton/index.tsx b/src/components/balances/HiddenTokenButton/index.tsx index 15b8a563..1d3bde28 100644 --- a/src/components/balances/HiddenTokenButton/index.tsx +++ b/src/components/balances/HiddenTokenButton/index.tsx @@ -22,10 +22,9 @@ const HiddenTokenButton = ({ balances.items?.filter((item) => currentHiddenAssets.includes(item.tokenInfo.address)).length || 0 return ( -
+
}) + +describe('CheckWallet', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('renders correctly when the wallet is connected to the right chain and is an owner', () => { + const { container } = renderButton() + + // Check that the button is enabled + expect(container.querySelector('button')).not.toBeDisabled() + }) + + it('should disable the button when the wallet is not connected', () => { + ;(useWallet as jest.MockedFunction).mockReturnValueOnce(null) + + const { container } = renderButton() + + // Check that the button is disabled + expect(container.querySelector('button')).toBeDisabled() + + // Check the tooltip text + expect(container.querySelector('span[aria-label]')).toHaveAttribute('aria-label', 'Please connect your wallet') + }) + + it('should disable the button when the wallet is connected to the right chain but is not an owner', () => { + ;(useIsSafeOwner as jest.MockedFunction).mockReturnValueOnce(false) + + const { container } = renderButton() + + expect(container.querySelector('button')).toBeDisabled() + expect(container.querySelector('span[aria-label]')).toHaveAttribute( + 'aria-label', + `Your connected wallet is not an owner of this Safe Account`, + ) + }) + + it('should not disable the button for non-owner spending limit benificiaries', () => { + ;(useIsSafeOwner as jest.MockedFunction).mockReturnValueOnce(false) + ;( + useIsOnlySpendingLimitBeneficiary as jest.MockedFunction + ).mockReturnValueOnce(true) + + const { container } = renderButton() + + expect(container.querySelector('button')).toBeDisabled() + expect(container.querySelector('span[aria-label]')).toHaveAttribute( + 'aria-label', + 'You can only create ERC-20 transactions within your spending limit', + ) + + const { container: allowContainer } = render( + {(isOk) => }, + ) + + expect(allowContainer.querySelector('button')).not.toBeDisabled() + }) + + it('should allow non-owners if specified', () => { + ;(useIsSafeOwner as jest.MockedFunction).mockReturnValueOnce(false) + + const { container } = render( + {(isOk) => }, + ) + + expect(container.querySelector('button')).not.toBeDisabled() + }) +}) diff --git a/src/components/common/CheckWallet/index.tsx b/src/components/common/CheckWallet/index.tsx new file mode 100644 index 00000000..c7b17c1a --- /dev/null +++ b/src/components/common/CheckWallet/index.tsx @@ -0,0 +1,43 @@ +import { type ReactElement } from 'react' +import { Tooltip } from '@mui/material' +import useIsOnlySpendingLimitBeneficiary from '@/hooks/useIsOnlySpendingLimitBeneficiary' +import useIsSafeOwner from '@/hooks/useIsSafeOwner' +import useWallet from '@/hooks/wallets/useWallet' +import useConnectWallet from '../ConnectWallet/useConnectWallet' + +type CheckWalletProps = { + children: (ok: boolean) => ReactElement + allowSpendingLimit?: boolean + allowNonOwner?: boolean +} + +enum Message { + WalletNotConnected = 'Please connect your wallet', + NotSafeOwner = 'Your connected wallet is not an owner of this Safe Account', + OnlySpendingLimitBeneficiary = 'You can only create ERC-20 transactions within your spending limit', +} + +const CheckWallet = ({ children, allowSpendingLimit, allowNonOwner }: CheckWalletProps): ReactElement => { + const wallet = useWallet() + const isSafeOwner = useIsSafeOwner() + const isSpendingLimit = useIsOnlySpendingLimitBeneficiary() + const connectWallet = useConnectWallet() + + const message = !wallet + ? Message.WalletNotConnected + : !isSafeOwner && !isSpendingLimit && !allowNonOwner + ? Message.NotSafeOwner + : isSpendingLimit && !allowSpendingLimit && !allowNonOwner + ? Message.OnlySpendingLimitBeneficiary + : '' + + if (!message) return children(true) + + return ( + + {children(false)} + + ) +} + +export default CheckWallet diff --git a/src/components/common/ConnectWallet/AccountCenter.tsx b/src/components/common/ConnectWallet/AccountCenter.tsx index edef2b5b..bed31ded 100644 --- a/src/components/common/ConnectWallet/AccountCenter.tsx +++ b/src/components/common/ConnectWallet/AccountCenter.tsx @@ -1,4 +1,3 @@ -import { useMemo } from 'react' import type { MouseEvent } from 'react' import { useState } from 'react' import { Box, Button, ButtonBase, Paper, Popover, Typography } from '@mui/material' @@ -14,17 +13,13 @@ import ChainSwitcher from '../ChainSwitcher' import useAddressBook from '@/hooks/useAddressBook' import { type ConnectedWallet } from '@/hooks/wallets/useOnboard' import WalletInfo, { UNKNOWN_CHAIN_NAME } from '../WalletInfo' -import { getShortName } from '@/utils/chains' const AccountCenter = ({ wallet }: { wallet: ConnectedWallet }) => { const [anchorEl, setAnchorEl] = useState(null) const onboard = useOnboard() const chainInfo = useAppSelector((state) => selectChainById(state, wallet.chainId)) const addressBook = useAddressBook() - - const prefix = useMemo(() => { - return chainInfo?.shortName || (wallet?.chainId && getShortName(wallet?.chainId)) - }, [chainInfo?.shortName, wallet?.chainId]) + const prefix = chainInfo?.shortName const handleSwitchWallet = () => { if (onboard) { diff --git a/src/components/common/ConnectWallet/useConnectWallet.ts b/src/components/common/ConnectWallet/useConnectWallet.ts index 5e60ee9e..9dc10e79 100644 --- a/src/components/common/ConnectWallet/useConnectWallet.ts +++ b/src/components/common/ConnectWallet/useConnectWallet.ts @@ -1,6 +1,6 @@ +import { useMemo } from 'react' import useOnboard, { connectWallet } from '@/hooks/wallets/useOnboard' import { OVERVIEW_EVENTS, trackEvent } from '@/services/analytics' -import { useMemo } from 'react' const useConnectWallet = (): (() => void) => { const onboard = useOnboard() diff --git a/src/components/common/CookieBanner/index.tsx b/src/components/common/CookieBanner/index.tsx index ade48176..15986bc7 100644 --- a/src/components/common/CookieBanner/index.tsx +++ b/src/components/common/CookieBanner/index.tsx @@ -1,7 +1,6 @@ import { useEffect, type ReactElement } from 'react' -// import Link from 'next/link' -// import MUILink from '@mui/material/Link' -import { Button, Checkbox, FormControlLabel, Typography, Paper, SvgIcon } from '@mui/material' +import type { CheckboxProps } from '@mui/material' +import { Grid, Button, Checkbox, FormControlLabel, Typography, Paper, SvgIcon, Box } from '@mui/material' import WarningIcon from '@/public/images/notifications/warning.svg' import { useForm } from 'react-hook-form' @@ -10,92 +9,164 @@ import { selectCookies, CookieType, saveCookieConsent } from '@/store/cookiesSli import { selectCookieBanner, openCookieBanner, closeCookieBanner } from '@/store/popupSlice' import css from './styles.module.css' +import { AppRoutes } from '@/config/routes' +import ExternalLink from '../ExternalLink' const COOKIE_WARNING: Record = { [CookieType.NECESSARY]: '', - [CookieType.UPDATES]: `You attempted to open the "What's new" section but need to accept the "Updates & Feedback" cookies first.`, + [CookieType.UPDATES]: `You attempted to open the "What's new" section but need to accept the "Beamer" cookies first.`, [CookieType.ANALYTICS]: '', } -const CookieBannerPopup = ({ warningKey }: { warningKey?: CookieType }): ReactElement => { +const CookieCheckbox = ({ + checkboxProps, + label, + checked, + color, +}: { + label: string + checked: boolean + checkboxProps: CheckboxProps + color?: string +}) => ( + } + sx={{ + mt: '-9px', + color, + '.MuiCheckbox-root': { + color, + }, + }} + /> +) + +export const CookieBanner = ({ + warningKey, + inverted, +}: { + warningKey?: CookieType + inverted?: boolean +}): ReactElement => { const warning = warningKey ? COOKIE_WARNING[warningKey] : undefined const dispatch = useAppDispatch() const cookies = useAppSelector(selectCookies) const { register, watch, getValues, setValue } = useForm({ defaultValues: { - ...cookies, + [CookieType.NECESSARY]: true, + [CookieType.UPDATES]: cookies[CookieType.UPDATES] ?? false, + [CookieType.ANALYTICS]: cookies[CookieType.ANALYTICS] ?? false, ...(warningKey ? { [warningKey]: true } : {}), }, }) const handleAccept = () => { - setValue(CookieType.NECESSARY, true) dispatch(saveCookieConsent(getValues())) dispatch(closeCookieBanner()) } const handleAcceptAll = () => { - setValue(CookieType.UPDATES, true) + // setValue(CookieType.UPDATES, true) setValue(CookieType.ANALYTICS, true) - - setTimeout(() => { - handleAccept() - }, 100) + setTimeout(handleAccept, 300) } + const color = inverted ? 'background.paper' : undefined + return ( - + {warning && ( - - {warning} + + {warning} )} - - We use cookies to provide you with the best experience and to help improve our website and application. By - clicking "Accept all", you agree to the storing of cookies on your device to enhance site navigation, - analyze site usage and provide customer support. - - -
- } - label="Necessary" - /> - - } - label="Updates (Beamer)" - checked={watch(CookieType.UPDATES)} - /> - - } - label="Analytics" - checked={watch(CookieType.ANALYTICS)} - /> - -
- - -
+ + + + + By clicking "Accept all" you agree to the use of the tools listed below and their corresponding{' '} + 3rd-party cookies.{' '} + + Cookie policy + + + + + + + +
+ + Locally stored data for core functionality + +
+ {/* + +
+ + New features and product announcements + +
*/} + + +
+ + Help us make the app better. We never track your Safe Account address or wallet addresses, or any + transaction data. + +
+
+
+ + + + + + + + + + + + +
+
) } -const CookieBanner = (): ReactElement | null => { +const CookieBannerPopup = (): ReactElement | null => { const cookiePopup = useAppSelector(selectCookieBanner) const cookies = useAppSelector(selectCookies) const dispatch = useAppDispatch() - // Open the banner if "necessary" cookies haven't been accepted - const shouldOpen = !cookies[CookieType.NECESSARY] + // Open the banner if cookie preferences haven't been set + const shouldOpen = cookies[CookieType.NECESSARY] === undefined + useEffect(() => { if (shouldOpen) { dispatch(openCookieBanner({})) @@ -104,7 +175,11 @@ const CookieBanner = (): ReactElement | null => { } }, [dispatch, shouldOpen]) - return cookiePopup?.open ? : null + return cookiePopup?.open ? ( +
+ +
+ ) : null } -export default CookieBanner +export default CookieBannerPopup diff --git a/src/components/common/CookieBanner/styles.module.css b/src/components/common/CookieBanner/styles.module.css index cbc83137..0dafa4fc 100644 --- a/src/components/common/CookieBanner/styles.module.css +++ b/src/components/common/CookieBanner/styles.module.css @@ -1,22 +1,14 @@ -.container { - padding: var(--space-3) var(--space-4); +.popup { position: fixed; z-index: 1300; - bottom: 0; - left: 0; - width: 100%; - border-radius: 0 !important; + bottom: var(--space-2); + right: var(--space-2); + max-width: 400px; } -.grid { - display: flex; - flex-wrap: wrap; - gap: var(--space-1); - justify-content: center; -} - -.container form { - margin-top: var(--space-2); +.container { + padding: var(--space-2); + border-radius: 0 !important; } .container label, @@ -26,6 +18,15 @@ @media (max-width: 600px) { .container { - padding: var(--space-3) var(--space-1); + right: 0; + bottom: 0; } } + +.container :global(.Mui-checked) { + color: var(--color-background-paper); +} + +.container :global(.Mui-checked.Mui-disabled) { + opacity: 0.5; +} diff --git a/src/components/common/CopyButton/index.tsx b/src/components/common/CopyButton/index.tsx index 98d9e846..9c14a511 100644 --- a/src/components/common/CopyButton/index.tsx +++ b/src/components/common/CopyButton/index.tsx @@ -18,24 +18,40 @@ const CopyButton = ({ onCopy?: () => void }): ReactElement => { const [tooltipText, setTooltipText] = useState(initialToolTipText) + const [isCopyEnabled, setIsCopyEnabled] = useState(true) const handleCopy = useCallback( (e: SyntheticEvent) => { e.preventDefault() e.stopPropagation() - navigator.clipboard.writeText(text).then(() => setTooltipText('Copied')) - onCopy?.() + try { + navigator.clipboard.writeText(text).then(() => setTooltipText('Copied')) + onCopy?.() + } catch (err) { + setIsCopyEnabled(false) + setTooltipText('Copying is disabled in your browser') + } }, [text, onCopy], ) const handleMouseLeave = useCallback(() => { - setTimeout(() => setTooltipText(initialToolTipText), 500) - }, [initialToolTipText]) + setTimeout(() => { + if (isCopyEnabled) { + setTooltipText(initialToolTipText) + } + }, 500) + }, [initialToolTipText, isCopyEnabled]) return ( - + {children ?? } diff --git a/src/components/common/DateTime/index.test.tsx b/src/components/common/DateTime/index.test.tsx index cc67df7d..a749c21d 100644 --- a/src/components/common/DateTime/index.test.tsx +++ b/src/components/common/DateTime/index.test.tsx @@ -9,6 +9,16 @@ jest.mock('@/utils/tx-history-filter', () => ({ })) describe('DateTime', () => { + beforeAll(() => { + // If we do not use a fixed date, this test will fail once a year (in some timezones) due to daylight saving time. + jest.useFakeTimers() + jest.setSystemTime(Date.parse('01.01.2023')) + }) + + afterAll(() => { + jest.useRealTimers() + }) + it('should render the relative time before threshold on the queue', () => { const date = new Date() const days = 3 diff --git a/src/components/common/EnhancedTable/index.tsx b/src/components/common/EnhancedTable/index.tsx index 28b148ff..240dfc66 100644 --- a/src/components/common/EnhancedTable/index.tsx +++ b/src/components/common/EnhancedTable/index.tsx @@ -21,7 +21,6 @@ type EnhancedCell = { content: ReactNode rawValue: string | number sticky?: boolean - hide?: boolean } type EnhancedRow = { @@ -36,7 +35,6 @@ type EnhancedHeadCell = { label: string width?: string sticky?: boolean - hide?: boolean } function descendingComparator(a: EnhancedRow, b: EnhancedRow, orderBy: string) { @@ -78,7 +76,7 @@ function EnhancedTableHead(props: EnhancedTableHeadProps) { padding="normal" sortDirection={orderBy === headCell.id ? order : false} sx={headCell.width ? { width: headCell.width } : undefined} - className={classNames({ sticky: headCell.sticky, [css.hide]: headCell.hide })} + className={classNames({ sticky: headCell.sticky })} > {headCell.label && ( <> @@ -154,7 +152,6 @@ function EnhancedTable({ rows, headCells, mobileVariant }: EnhancedTableProps) { key={key} className={classNames({ sticky: cell.sticky, - [css.hide]: cell.hide, [css.collapsedCell]: row.collapsed, })} > diff --git a/src/components/common/EnhancedTable/styles.module.css b/src/components/common/EnhancedTable/styles.module.css index eeb1c213..f1f78614 100644 --- a/src/components/common/EnhancedTable/styles.module.css +++ b/src/components/common/EnhancedTable/styles.module.css @@ -1,7 +1,3 @@ -.hide { - display: none; -} - .tableCell { transition: padding 0s; } diff --git a/src/components/common/ErrorBoundary/index.tsx b/src/components/common/ErrorBoundary/index.tsx index d726b6e3..e277db30 100644 --- a/src/components/common/ErrorBoundary/index.tsx +++ b/src/components/common/ErrorBoundary/index.tsx @@ -1,7 +1,7 @@ import { Typography, Link } from '@mui/material' import type { FallbackRender } from '@sentry/react' -import { IS_PRODUCTION } from '@/config/constants' +import { HELP_CENTER_URL, IS_PRODUCTION } from '@/config/constants' import { AppRoutes } from '@/config/routes' import WarningIcon from '@/public/images/notifications/warning.svg' @@ -24,7 +24,7 @@ const ErrorBoundary: FallbackRender = ({ error, componentStack }) => { {IS_PRODUCTION ? ( In case the problem persists, please reach out to us via our{' '} - Help Center + Help Center ) : ( <> diff --git a/src/components/common/Footer/index.tsx b/src/components/common/Footer/index.tsx index 8fc300a9..d1d7c374 100644 --- a/src/components/common/Footer/index.tsx +++ b/src/components/common/Footer/index.tsx @@ -1,15 +1,14 @@ -import type { SyntheticEvent, ReactElement } from 'react' +import type { ReactElement, ReactNode } from 'react' import { Typography } from '@mui/material' import Link from 'next/link' import { useRouter } from 'next/router' import css from './styles.module.css' -import { useAppDispatch } from '@/store' -import { openCookieBanner } from '@/store/popupSlice' import { AppRoutes } from '@/config/routes' import packageJson from '../../../../package.json' //import AppstoreButton from '../AppStoreButton' import ExternalLink from '../ExternalLink' -// import MUILink from '@mui/material/Link' +import MUILink from '@mui/material/Link' +import { IS_OFFICIAL_HOST } from '@/config/constants' const footerPages = [ AppRoutes.welcome, @@ -18,68 +17,73 @@ const footerPages = [ AppRoutes.privacy, AppRoutes.cookie, AppRoutes.terms, + AppRoutes.licenses, ] +const FooterLink = ({ children, href }: { children: ReactNode; href: string }): ReactElement => { + return href ? ( + + {children} + + ) : ( + {children} + ) +} + const Footer = (): ReactElement | null => { const router = useRouter() - const dispatch = useAppDispatch() if (!footerPages.some((path) => router.pathname.startsWith(path))) { return null } - const onCookieClick = (e: SyntheticEvent) => { - e.preventDefault() - dispatch(openCookieBanner({})) + const getHref = (path: string): string => { + return router.pathname === path ? '' : path } return (
    + {IS_OFFICIAL_HOST === false ? ( + <> +
  • + ©2022–{new Date().getFullYear()} Evmos Safe +
  • +
  • + + Evmos Network + +
  • +
  • + + Evmos Documentation + +
  • + {/*
  • + Terms +
  • +
  • + Privacy +
  • +
  • + Licenses +
  • +
  • + Imprint +
  • +
  • + Cookie policy +
  • */} +
  • + Preferences +
  • + + ) : ( +
  • {'This is an unofficial distribution of Evmos Safe'}
  • + )} +
  • - ©2022–{new Date().getFullYear()} Evmos -
  • -
  • - - Evmos Network - -
  • -
  • - - Evmos Documentation - -
  • - {/*
  • - - Terms - -
  • -
  • - - Privacy - -
  • -
  • - - Licenses - -
  • -
  • - - Imprint - -
  • */} -
  • - {/* - Cookie Policy - -  —  */} - - Cookie Preferences - -
  • -
  • - + v{packageJson.version}
  • diff --git a/src/components/common/Footer/styles.module.css b/src/components/common/Footer/styles.module.css index 9df45694..2631f4f2 100644 --- a/src/components/common/Footer/styles.module.css +++ b/src/components/common/Footer/styles.module.css @@ -25,6 +25,11 @@ margin-left: var(--space-2); } +.container li a:not([href]) { + text-decoration: none; + pointer-events: none; +} + @media (max-width: 600px) { .container li:not(:last-of-type):after { visibility: hidden; diff --git a/src/components/common/Header/index.tsx b/src/components/common/Header/index.tsx index d93d8a89..d66f8c49 100644 --- a/src/components/common/Header/index.tsx +++ b/src/components/common/Header/index.tsx @@ -5,7 +5,6 @@ import { IconButton, Paper } from '@mui/material' import MenuIcon from '@mui/icons-material/Menu' import classnames from 'classnames' import css from './styles.module.css' -import ChainSwitcher from '@/components/common/ChainSwitcher' import ConnectWallet from '@/components/common/ConnectWallet' import NetworkSelector from '@/components/common/NetworkSelector' import SafeTokenWidget, { getSafeTokenAddress } from '@/components/common/SafeTokenWidget' @@ -53,10 +52,6 @@ const Header = ({ onMenuToggle }: HeaderProps): ReactElement => {
-
- -
- {showSafeToken && (
@@ -67,7 +62,7 @@ const Header = ({ onMenuToggle }: HeaderProps): ReactElement => {
-
+
diff --git a/src/components/common/Header/styles.module.css b/src/components/common/Header/styles.module.css index 6b6fe616..2ea22b58 100644 --- a/src/components/common/Header/styles.module.css +++ b/src/components/common/Header/styles.module.css @@ -33,6 +33,10 @@ height: 60px; } +.logo { + padding: var(--space-2); +} + .menuButton { display: none; } @@ -43,6 +47,10 @@ border-right: none; } +.connectWallet { + flex-shrink: 0; +} + @media (max-width: 900px) { .logo { display: none; diff --git a/src/components/common/ModalDialog/index.tsx b/src/components/common/ModalDialog/index.tsx index f9455518..cede7fe2 100644 --- a/src/components/common/ModalDialog/index.tsx +++ b/src/components/common/ModalDialog/index.tsx @@ -1,9 +1,9 @@ -import type { ModalProps } from '@mui/material' -import { Dialog, DialogTitle, type DialogProps, IconButton, useMediaQuery } from '@mui/material' +import { type ReactElement, type ReactNode } from 'react' +import { IconButton, type ModalProps } from '@mui/material' +import { Dialog, DialogTitle, type DialogProps, useMediaQuery } from '@mui/material' import { useTheme } from '@mui/material/styles' -import CloseIcon from '@mui/icons-material/Close' import ChainIndicator from '@/components/common/ChainIndicator' -import * as React from 'react' +import CloseIcon from '@mui/icons-material/Close' import css from './styles.module.css' @@ -13,7 +13,7 @@ interface ModalDialogProps extends DialogProps { } interface DialogTitleProps { - children: React.ReactNode + children: ReactNode onClose?: ModalProps['onClose'] hideChainIndicator?: boolean } @@ -49,7 +49,7 @@ const ModalDialog = ({ children, fullScreen = false, ...restProps -}: ModalDialogProps): React.ReactElement => { +}: ModalDialogProps): ReactElement => { const theme = useTheme() const isSmallScreen = useMediaQuery(theme.breakpoints.down('sm')) const isFullScreen = fullScreen || isSmallScreen diff --git a/src/components/common/NavTabs/index.tsx b/src/components/common/NavTabs/index.tsx index cb37d366..1d0adc8a 100644 --- a/src/components/common/NavTabs/index.tsx +++ b/src/components/common/NavTabs/index.tsx @@ -33,29 +33,28 @@ const NextLinkComposed = forwardRef(function NextCompo const NavTabs = ({ tabs }: { tabs: NavItem[] }) => { const router = useRouter() const activeTab = tabs.map((tab) => tab.href).indexOf(router.pathname) + const query = router.query.safe ? { safe: router.query.safe } : undefined return ( - {tabs.map((tab, idx) => { - return ( - - {tab.label} - - } - /> - ) - })} + {tabs.map((tab, idx) => ( + + {tab.label} + + } + /> + ))} ) } diff --git a/src/components/common/NavTabs/styles.module.css b/src/components/common/NavTabs/styles.module.css index 567b2ae9..14c0ffe5 100644 --- a/src/components/common/NavTabs/styles.module.css +++ b/src/components/common/NavTabs/styles.module.css @@ -12,11 +12,11 @@ } .tabs :global .MuiTabScrollButton-root:first-of-type { - margin-left: -24px; + margin-left: calc(var(--space-2) * -1); } .tabs :global .MuiTabScrollButton-root:last-of-type { - margin-right: -24px; + margin-right: calc(var(--space-2) * -1); } .tab { diff --git a/src/components/common/NetworkSelector/index.tsx b/src/components/common/NetworkSelector/index.tsx index 95800dbc..e4ef59e0 100644 --- a/src/components/common/NetworkSelector/index.tsx +++ b/src/components/common/NetworkSelector/index.tsx @@ -1,5 +1,5 @@ +import Link from 'next/link' import type { SelectChangeEvent } from '@mui/material' -import { Chip } from '@mui/material' import { MenuItem, Select, Skeleton } from '@mui/material' import ExpandMoreIcon from '@mui/icons-material/ExpandMore' import useChains from '@/hooks/useChains' @@ -7,64 +7,56 @@ import { useRouter } from 'next/router' import ChainIndicator from '../ChainIndicator' import css from './styles.module.css' import { useChainId } from '@/hooks/useChainId' -import { getShortName } from '@/utils/chains' import type { ReactElement } from 'react' +import { useCallback } from 'react' import { AppRoutes } from '@/config/routes' import { trackEvent, OVERVIEW_EVENTS } from '@/services/analytics' -/** - * The dates when the chain was added to the app - * Show a "New!" label for two weeks after the chain was added - */ -const networkAddedDates: Record = { - 'base-gor': '2023-02-24', -} -const maxNewDays = 14 - -const isNetworkNew = (network: string): boolean => { - const addedDate = networkAddedDates[network] - if (!addedDate) return false - const added = new Date(addedDate).getTime() - const elapsed = Date.now() - added - return elapsed < maxNewDays * 24 * 60 * 60 * 1000 -} +const keepPathRoutes = [AppRoutes.welcome, AppRoutes.newSafe.create, AppRoutes.newSafe.load] const NetworkSelector = (): ReactElement => { const { configs } = useChains() const chainId = useChainId() const router = useRouter() + const getNetworkLink = useCallback( + (shortName: string) => { + const shouldKeepPath = keepPathRoutes.includes(router.pathname) - const handleNetworkSwitch = (event: SelectChangeEvent) => { - const selectedChainId = event.target.value - const newShortName = getShortName(selectedChainId) + const route = { + pathname: shouldKeepPath ? router.pathname : '/', + query: { + chain: shortName, + } as { + chain: string + safeViewRedirectURL?: string + }, + } - if (!newShortName) return + if (router.query?.safeViewRedirectURL) { + route.query.safeViewRedirectURL = router.query?.safeViewRedirectURL.toString() + } - trackEvent({ ...OVERVIEW_EVENTS.SWITCH_NETWORK, label: selectedChainId }) + return route + }, + [router], + ) - const shouldKeepPath = [AppRoutes.newSafe.create, AppRoutes.newSafe.load].includes(router.pathname) + const onChange = (event: SelectChangeEvent) => { + event.preventDefault() // Prevent the link click - const newRoute = { - pathname: shouldKeepPath ? router.pathname : '/', - query: { - chain: newShortName, - } as { - chain: string - safeViewRedirectURL?: string - }, - } + const newChainId = event.target.value + const shortName = configs.find((item) => item.chainId === newChainId)?.shortName - if (router.query?.safeViewRedirectURL) { - newRoute.query.safeViewRedirectURL = router.query?.safeViewRedirectURL.toString() + if (shortName) { + trackEvent({ ...OVERVIEW_EVENTS.SWITCH_NETWORK, label: newChainId }) + router.push(getNetworkLink(shortName)) } - - return router.push(newRoute) } return configs.length ? ( ({ width: '311px', minHeight: '56px', @@ -139,7 +139,7 @@ const AppActions = ({ wallet, onConnectWallet, chain, appUrl, app }: Props): Rea height={CTA_HEIGHT} > - Use the App with your Safe + Use the App with your Safe Account {body} {button} diff --git a/src/components/safe-apps/SafeAppLandingPage/SafeAppDetails.tsx b/src/components/safe-apps/SafeAppLandingPage/SafeAppDetails.tsx index 7ca0caef..248d2fbf 100644 --- a/src/components/safe-apps/SafeAppLandingPage/SafeAppDetails.tsx +++ b/src/components/safe-apps/SafeAppLandingPage/SafeAppDetails.tsx @@ -28,7 +28,7 @@ const SafeAppDetails = ({ app, showDefaultListWarning }: DetailsProps) => ( - App URL + Safe App URL ({ diff --git a/src/components/safe-apps/SafeAppLandingPage/TryDemo.tsx b/src/components/safe-apps/SafeAppLandingPage/TryDemo.tsx index e8b07da1..c5da5ca0 100644 --- a/src/components/safe-apps/SafeAppLandingPage/TryDemo.tsx +++ b/src/components/safe-apps/SafeAppLandingPage/TryDemo.tsx @@ -12,7 +12,7 @@ type Props = { const TryDemo = ({ demoUrl, onClick }: Props) => ( - Try the app before using it + Try the Safe App before using it diff --git a/src/components/safe-apps/SafeAppLandingPage/index.tsx b/src/components/safe-apps/SafeAppLandingPage/index.tsx index 8b32b06c..2f14c7c2 100644 --- a/src/components/safe-apps/SafeAppLandingPage/index.tsx +++ b/src/components/safe-apps/SafeAppLandingPage/index.tsx @@ -58,7 +58,7 @@ const SafeAppLanding = ({ appUrl, chain }: Props) => { } if (!safeApp) { - return
No safe app found
+ return
No Safe App found
} return ( diff --git a/src/components/safe-apps/SafeAppList/index.tsx b/src/components/safe-apps/SafeAppList/index.tsx index 5feda96a..72108188 100644 --- a/src/components/safe-apps/SafeAppList/index.tsx +++ b/src/components/safe-apps/SafeAppList/index.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback } from 'react' +import { useCallback } from 'react' import type { SafeAppData } from '@safe-global/safe-gateway-typescript-sdk' import classnames from 'classnames' @@ -13,6 +13,7 @@ import useSafeAppsFilters from '@/hooks/safe-apps/useSafeAppsFilters' import useSafeAppPreviewDrawer from '@/hooks/safe-apps/useSafeAppPreviewDrawer' import css from './styles.module.css' import { Skeleton } from '@mui/material' +import useLocalStorage from '@/services/local-storage/useLocalStorage' type SafeAppListProps = { safeAppsList: SafeAppData[] @@ -24,6 +25,8 @@ type SafeAppListProps = { removeCustomApp?: (safeApp: SafeAppData) => void } +const VIEW_MODE_KEY = 'SafeApps_viewMode' + const SafeAppList = ({ safeAppsList, safeAppsListLoading, @@ -33,7 +36,7 @@ const SafeAppList = ({ addCustomApp, removeCustomApp, }: SafeAppListProps) => { - const [safeAppsViewMode, setSafeAppsViewMode] = useState(GRID_VIEW_MODE) + const [safeAppsViewMode = GRID_VIEW_MODE, setSafeAppsViewMode] = useLocalStorage(VIEW_MODE_KEY) const { isPreviewDrawerOpen, previewDrawerApp, openPreviewDrawer, closePreviewDrawer } = useSafeAppPreviewDrawer() const { filteredApps, query, setQuery, setSelectedCategories, setOptimizedWithBatchFilter, selectedCategories } = @@ -72,7 +75,7 @@ const SafeAppList = ({ setSafeAppsViewMode={setSafeAppsViewMode} /> - {/* Safe App List */} + {/* Safe Apps List */}
    ))} - {filteredApps.map((safeApp) => { - return ( -
  • - -
  • - ) - })} + {/* Flat list filtered by search query */} + {filteredApps.map((safeApp) => ( +
  • + +
  • + ))}
{/* Zero results placeholder */} diff --git a/src/components/safe-apps/SafeAppPreviewDrawer/index.tsx b/src/components/safe-apps/SafeAppPreviewDrawer/index.tsx index 335e3af0..de67676f 100644 --- a/src/components/safe-apps/SafeAppPreviewDrawer/index.tsx +++ b/src/components/safe-apps/SafeAppPreviewDrawer/index.tsx @@ -83,7 +83,7 @@ const SafeAppPreviewDrawer = ({ isOpen, safeApp, isBookmarked, onClose, onBookma {/* Open Safe App button */} diff --git a/src/components/safe-apps/SafeAppSocialLinksCard/SafeAppSocialLinksCard.test.tsx b/src/components/safe-apps/SafeAppSocialLinksCard/SafeAppSocialLinksCard.test.tsx index 52c010fc..6825adf0 100644 --- a/src/components/safe-apps/SafeAppSocialLinksCard/SafeAppSocialLinksCard.test.tsx +++ b/src/components/safe-apps/SafeAppSocialLinksCard/SafeAppSocialLinksCard.test.tsx @@ -47,7 +47,7 @@ describe('SafeAppSocialLinksCard', () => { render() await waitFor(() => { - expect(screen.queryByText('Something wrong with the app?')).not.toBeInTheDocument() + expect(screen.queryByText('Something wrong with the Safe App?')).not.toBeInTheDocument() }) }) @@ -59,7 +59,7 @@ describe('SafeAppSocialLinksCard', () => { render() await waitFor(() => { - expect(screen.queryByText('Something wrong with the app?')).toBeInTheDocument() + expect(screen.queryByText('Something wrong with the Safe App?')).toBeInTheDocument() expect(screen.queryByText(developerWebsiteMock)).toBeInTheDocument() }) }) @@ -72,7 +72,7 @@ describe('SafeAppSocialLinksCard', () => { render() await waitFor(() => { - expect(screen.queryByText('Something wrong with the app?')).toBeInTheDocument() + expect(screen.queryByText('Something wrong with the Safe App?')).toBeInTheDocument() expect(screen.getByLabelText('Discord link')).toBeInTheDocument() expect(screen.getByLabelText('Twitter link')).toBeInTheDocument() expect(screen.getByLabelText('Github link')).toBeInTheDocument() @@ -88,7 +88,7 @@ describe('SafeAppSocialLinksCard', () => { render() await waitFor(() => { - expect(screen.queryByText('Something wrong with the app?')).toBeInTheDocument() + expect(screen.queryByText('Something wrong with the Safe App?')).toBeInTheDocument() expect(screen.queryByText(developerWebsiteMock)).toBeInTheDocument() expect(screen.getByLabelText('Discord link')).toBeInTheDocument() expect(screen.getByLabelText('Twitter link')).toBeInTheDocument() @@ -105,7 +105,7 @@ describe('SafeAppSocialLinksCard', () => { render() await waitFor(() => { - expect(screen.queryByText('Something wrong with the app?')).toBeInTheDocument() + expect(screen.queryByText('Something wrong with the Safe App?')).toBeInTheDocument() expect(screen.queryByLabelText('Discord link')).toBeInTheDocument() expect(screen.queryByLabelText('Twitter link')).not.toBeInTheDocument() expect(screen.queryByLabelText('Github link')).not.toBeInTheDocument() diff --git a/src/components/safe-apps/SafeAppSocialLinksCard/index.tsx b/src/components/safe-apps/SafeAppSocialLinksCard/index.tsx index 3024e84c..67600b9b 100644 --- a/src/components/safe-apps/SafeAppSocialLinksCard/index.tsx +++ b/src/components/safe-apps/SafeAppSocialLinksCard/index.tsx @@ -40,7 +40,7 @@ const SafeAppSocialLinksCard = ({ safeApp }: SafeAppSocialLinksCardProps) => {
- Something wrong with the app? + Something wrong with the Safe App? Get in touch with the team diff --git a/src/components/safe-apps/SafeAppsErrorBoundary/SafeAppsLoadError.tsx b/src/components/safe-apps/SafeAppsErrorBoundary/SafeAppsLoadError.tsx index 6fdb58f2..77acb84c 100644 --- a/src/components/safe-apps/SafeAppsErrorBoundary/SafeAppsLoadError.tsx +++ b/src/components/safe-apps/SafeAppsErrorBoundary/SafeAppsLoadError.tsx @@ -1,7 +1,7 @@ import Typography from '@mui/material/Typography' import Button from '@mui/material/Button' import SvgIcon from '@mui/material/SvgIcon' -import { SAFE_APPS_SUPPORT_CHAT_URL } from '@/config/constants' +import { DISCORD_URL } from '@/config/constants' import NetworkError from '@/public/images/apps/network-error.svg' import css from './styles.module.css' @@ -21,7 +21,7 @@ const SafeAppsLoadError = ({ onBackToApps }: SafeAppsLoadErrorProps): React.Reac
In case the problem persists, please reach out to us via - + Discord
diff --git a/src/components/safe-apps/SafeAppsFilters/index.tsx b/src/components/safe-apps/SafeAppsFilters/index.tsx index 72e7b30b..ecb7ee2e 100644 --- a/src/components/safe-apps/SafeAppsFilters/index.tsx +++ b/src/components/safe-apps/SafeAppsFilters/index.tsx @@ -18,7 +18,7 @@ import CloseIcon from '@mui/icons-material/Close' import type { SelectChangeEvent } from '@mui/material/Select' import type { SafeAppData } from '@safe-global/safe-gateway-typescript-sdk' -import { filterInternalCategories } from '@/components/safe-apps/utils' +import { getUniqueTags } from '@/components/safe-apps/utils' import SearchIcon from '@/public/images/common/search.svg' import BatchIcon from '@/public/images/apps/batch-icon.svg' import css from './styles.module.css' @@ -191,21 +191,8 @@ const categoryMenuProps = { } const getCategoryOptions = (safeAppList: SafeAppData[]): safeAppCatogoryOptionType[] => { - return safeAppList.reduce((categoryOptions, safeApp) => { - // we filter internal categories - const categories = filterInternalCategories(safeApp.tags) - - // avoid repeated categories - const removeRepeatedCategories = categories.filter( - (category) => !categoryOptions.some((option) => option.value === category), - ) - - // from string[] to Object[] (label & value) - const newCategoryOptions = removeRepeatedCategories.map((category) => ({ - label: category, - value: category, - })) - - return [...categoryOptions, ...newCategoryOptions] - }, []) + return getUniqueTags(safeAppList).map((category) => ({ + label: category, + value: category, + })) } diff --git a/src/components/safe-apps/SafeAppsHeader/index.tsx b/src/components/safe-apps/SafeAppsHeader/index.tsx index cc4253ba..6e3459a1 100644 --- a/src/components/safe-apps/SafeAppsHeader/index.tsx +++ b/src/components/safe-apps/SafeAppsHeader/index.tsx @@ -17,8 +17,7 @@ const SafeAppsHeader = (): ReactElement => { {/* Safe Apps Subtitle */} - Connect to your favorite web3 applications with your Safe smart contract account. Safer and more efficient, - right from the interface. + Connect to your favourite web3 applications with your Safe Account, securely and efficiently. diff --git a/src/components/safe-apps/SafeAppsInfoModal/AllowedFeaturesList.tsx b/src/components/safe-apps/SafeAppsInfoModal/AllowedFeaturesList.tsx index 48ac3d6d..58966973 100644 --- a/src/components/safe-apps/SafeAppsInfoModal/AllowedFeaturesList.tsx +++ b/src/components/safe-apps/SafeAppsInfoModal/AllowedFeaturesList.tsx @@ -28,11 +28,11 @@ const AllowedFeaturesList: React.FC = ({ margin: '0 75px', }} > - Manage the features Safe App can use + Manage the features Safe Apps can use
- This app is requesting permission to use: + This Safe App is requesting permission to use: {features diff --git a/src/components/safe-apps/SafeAppsInfoModal/LegalDisclaimer.tsx b/src/components/safe-apps/SafeAppsInfoModal/LegalDisclaimer.tsx index e89674aa..a3fe4a7b 100644 --- a/src/components/safe-apps/SafeAppsInfoModal/LegalDisclaimer.tsx +++ b/src/components/safe-apps/SafeAppsInfoModal/LegalDisclaimer.tsx @@ -1,4 +1,5 @@ import ExternalLink from '@/components/common/ExternalLink' +import { AppRoutes } from '@/config/routes' import { Typography } from '@mui/material' import css from './styles.module.css' @@ -23,7 +24,7 @@ const LegalDisclaimer = (): JSX.Element => ( I have read and understood the{' '} - + Terms {' '} and this Disclaimer, and agree to be bound by them. diff --git a/src/components/safe-apps/SafeAppsInfoModal/constants.ts b/src/components/safe-apps/SafeAppsInfoModal/constants.ts index 374befc2..6fc808ce 100644 --- a/src/components/safe-apps/SafeAppsInfoModal/constants.ts +++ b/src/components/safe-apps/SafeAppsInfoModal/constants.ts @@ -1,7 +1,7 @@ export const SECURITY_PRACTICES = [ { id: '1', - title: 'Always load Safe Apps from trusted sources.', + title: 'Always load a Safe App from trusted sources.', imageSrc: './safe-apps-security-practices/1.png', }, { @@ -11,7 +11,7 @@ export const SECURITY_PRACTICES = [ }, { id: '3', - title: 'Always check transaction information while creating it, before proposing it to the Safe.', + title: 'Always check transaction information while creating it, before proposing it to the Safe Account.', imageSrc: './safe-apps-security-practices/2.png', }, { diff --git a/src/components/safe-apps/SafeAppsListHeader/index.tsx b/src/components/safe-apps/SafeAppsListHeader/index.tsx index 35a233c8..9551628d 100644 --- a/src/components/safe-apps/SafeAppsListHeader/index.tsx +++ b/src/components/safe-apps/SafeAppsListHeader/index.tsx @@ -9,6 +9,7 @@ import ListViewIcon from '@/public/images/apps/list-view-icon.svg' import { GRID_VIEW_MODE, LIST_VIEW_MODE } from '@/components/safe-apps/SafeAppCard' import type { SafeAppsViewMode } from '@/components/safe-apps/SafeAppCard' import css from './styles.module.css' +import { SAFE_APPS_EVENTS, trackEvent } from '@/services/analytics' type SafeAppsListHeaderProps = { amount?: number @@ -31,7 +32,8 @@ const SafeAppsListHeader = ({ amount, safeAppsViewMode, setSafeAppsViewMode }: S aria-label="safe apps view mode selector" name="safe-apps-view-mode" sx={{ flexDirection: 'row' }} - onChange={(e, viewMode) => { + onChange={(_, viewMode) => { + trackEvent({ ...SAFE_APPS_EVENTS.SWITCH_LIST_VIEW, label: viewMode }) setSafeAppsViewMode(viewMode as SafeAppsViewMode) }} > diff --git a/src/components/safe-apps/SafeAppsSDKLink/index.tsx b/src/components/safe-apps/SafeAppsSDKLink/index.tsx index 9393b7a7..68fbc4e1 100644 --- a/src/components/safe-apps/SafeAppsSDKLink/index.tsx +++ b/src/components/safe-apps/SafeAppsSDKLink/index.tsx @@ -29,7 +29,7 @@ const SafeAppsSDKLink = () => { - How to build on Safe? + How to build on Safe? diff --git a/src/components/safe-apps/SafeAppsSignMessageModal/ReviewSafeAppsSignMessage.tsx b/src/components/safe-apps/SafeAppsSignMessageModal/ReviewSafeAppsSignMessage.tsx index b8889437..a22f775f 100644 --- a/src/components/safe-apps/SafeAppsSignMessageModal/ReviewSafeAppsSignMessage.tsx +++ b/src/components/safe-apps/SafeAppsSignMessageModal/ReviewSafeAppsSignMessage.tsx @@ -17,11 +17,14 @@ import { generateDataRowValue } from '@/components/transactions/TxDetails/Summar import type { SafeAppsSignMessageParams } from '@/components/safe-apps/SafeAppsSignMessageModal' import useChainId from '@/hooks/useChainId' import useAsync from '@/hooks/useAsync' -import { getSignMessageLibDeploymentContractInstance } from '@/services/contracts/safeContracts' -import useTxSender from '@/hooks/useTxSender' +import { getReadOnlySignMessageLibContract } from '@/services/contracts/safeContracts' import { DecodedMsg } from '@/components/safe-messages/DecodedMsg' import CopyButton from '@/components/common/CopyButton' import { getDecodedMessage } from '@/components/safe-apps/utils' +import { createTx, dispatchSafeAppsTx } from '@/services/tx/tx-sender' +import useOnboard from '@/hooks/wallets/useOnboard' +import useSafeInfo from '@/hooks/useSafeInfo' +import useHighlightHiddenTab from '@/hooks/useHighlightHiddenTab' type ReviewSafeAppsSignMessageProps = { safeAppsSignMessage: SafeAppsSignMessageParams @@ -31,14 +34,17 @@ const ReviewSafeAppsSignMessage = ({ safeAppsSignMessage: { message, method, requestId }, }: ReviewSafeAppsSignMessageProps): ReactElement => { const chainId = useChainId() - const { createTx, dispatchSafeAppsTx } = useTxSender() + const { safe } = useSafeInfo() + const onboard = useOnboard() const [submitError, setSubmitError] = useState() + useHighlightHiddenTab() + const isTextMessage = method === Methods.signMessage && typeof message === 'string' const isTypedMessage = method === Methods.signTypedMessage && isObjectEIP712TypedData(message) - const signMessageDeploymentInstance = useMemo(() => getSignMessageLibDeploymentContractInstance(chainId), [chainId]) - const signMessageAddress = signMessageDeploymentInstance.getAddress() + const readOnlySignMessageLibContract = useMemo(() => getReadOnlySignMessageLibContract(chainId), [chainId]) + const signMessageAddress = readOnlySignMessageLibContract.getAddress() const [decodedMessage, readableMessage] = useMemo(() => { if (isTextMessage) { @@ -54,7 +60,7 @@ const ReviewSafeAppsSignMessage = ({ let txData if (isTextMessage) { - txData = signMessageDeploymentInstance.encode('signMessage', [hashMessage(getDecodedMessage(message))]) + txData = readOnlySignMessageLibContract.encode('signMessage', [hashMessage(getDecodedMessage(message))]) } else if (isTypedMessage) { const typesCopy = { ...message.types } @@ -63,7 +69,7 @@ const ReviewSafeAppsSignMessage = ({ // The types are not allowed to be recursive, so ever type must either be used by another type, or be // the primary type. And there must only be one type that is not used by any other type. delete typesCopy.EIP712Domain - txData = signMessageDeploymentInstance.encode('signMessage', [ + txData = readOnlySignMessageLibContract.encode('signMessage', [ _TypedDataEncoder.hash(message.domain, typesCopy, message.message), ]) } @@ -74,13 +80,13 @@ const ReviewSafeAppsSignMessage = ({ data: txData || '0x', operation: OperationType.DelegateCall, }) - }, [message, createTx]) + }, [message]) const handleSubmit = async () => { setSubmitError(undefined) - if (!safeTx) return + if (!safeTx || !onboard) return try { - await dispatchSafeAppsTx(safeTx, requestId) + await dispatchSafeAppsTx(safeTx, requestId, onboard, safe.chainId) } catch (error) { setSubmitError(error as Error) } @@ -115,7 +121,9 @@ const ReviewSafeAppsSignMessage = ({ - Signing a message with the Safe requires a transaction on the blockchain + + Signing a message with your Safe Account requires a transaction on the blockchain + diff --git a/src/components/safe-apps/SafeAppsSignMessageModal/SafeAppsSignMessageModal.test.tsx b/src/components/safe-apps/SafeAppsSignMessageModal/SafeAppsSignMessageModal.test.tsx index 1a227d05..4bcd6e99 100644 --- a/src/components/safe-apps/SafeAppsSignMessageModal/SafeAppsSignMessageModal.test.tsx +++ b/src/components/safe-apps/SafeAppsSignMessageModal/SafeAppsSignMessageModal.test.tsx @@ -7,7 +7,7 @@ import { SafeAppAccessPolicyTypes } from '@safe-global/safe-gateway-typescript-s describe('SafeAppsSignMessageModal', () => { test('can handle messages with EIP712Domain type in the JSON-RPC payload', () => { - jest.spyOn(web3, 'getWeb3').mockImplementation(() => new Web3Provider(jest.fn())) + jest.spyOn(web3, 'getWeb3ReadOnly').mockImplementation(() => new Web3Provider(jest.fn())) render( { - const { createMultiSendCallOnlyTx, dispatchSafeAppsTx, createTx } = useTxSender() - const chainId = useChainId() + const { safe } = useSafeInfo() + const onboard = useOnboard() const chain = useCurrentChain() + const [txList, setTxList] = useState(txs) const [submitError, setSubmitError] = useState() + const isMultiSend = txList.length > 1 - const isMultiSend = txs.length > 1 + useHighlightHiddenTab() const [safeTx, safeTxError] = useAsync(async () => { - const tx = isMultiSend ? await createMultiSendCallOnlyTx(txs) : await createTx(txs[0]) + const tx = isMultiSend ? await createMultiSendCallOnlyTx(txList) : await createTx(txList[0]) if (params?.safeTxGas) { // FIXME: do it properly via the Core SDK @@ -44,21 +45,15 @@ const ReviewSafeAppsTx = ({ } return tx - }, [txs, createMultiSendCallOnlyTx]) - - const [decodedData] = useAsync(async () => { - if (!safeTx || isEmptyHexData(safeTx.data.data)) return - - return getDecodedData(chainId, safeTx.data.data) - }, [safeTx, chainId]) + }, [txList]) const handleSubmit = async () => { setSubmitError(undefined) - if (!safeTx) return + if (!safeTx || !onboard) return trackSafeAppTxCount(Number(appId)) try { - await dispatchSafeAppsTx(safeTx, requestId) + await dispatchSafeAppsTx(safeTx, requestId, onboard, safe.chainId) } catch (error) { setSubmitError(error as Error) } @@ -69,6 +64,10 @@ const ReviewSafeAppsTx = ({ return ( <> + Error parsing data
}> + + + {safeTx && ( @@ -81,21 +80,6 @@ const ReviewSafeAppsTx = ({ {generateDataRowValue(safeTx.data.data, 'rawData')} - - {isMultiSend && ( - - - - )} )} diff --git a/src/components/safe-apps/SafeAppsZeroResultsPlaceholder/index.tsx b/src/components/safe-apps/SafeAppsZeroResultsPlaceholder/index.tsx index 562043b7..3112f8cb 100644 --- a/src/components/safe-apps/SafeAppsZeroResultsPlaceholder/index.tsx +++ b/src/components/safe-apps/SafeAppsZeroResultsPlaceholder/index.tsx @@ -32,8 +32,8 @@ const SafeAppsZeroResultsPlaceholder = ({ searchQuery }: { searchQuery: string } img={} text={ - No apps found matching {searchQuery}. Connect to dApps that haven't yet been integrated - with the Safe using the WalletConnect App. + No Safe Apps found matching {searchQuery}. Connect to dApps that haven't yet been + integrated with the {'Evmos Safe'} using the WalletConnect Safe App. } > diff --git a/src/components/safe-apps/hooks/useShareSafeAppUrl.ts b/src/components/safe-apps/hooks/useShareSafeAppUrl.ts new file mode 100644 index 00000000..1cde9300 --- /dev/null +++ b/src/components/safe-apps/hooks/useShareSafeAppUrl.ts @@ -0,0 +1,30 @@ +import { useRouter } from 'next/router' +import { resolveHref } from 'next/dist/shared/lib/router/router' +import { useEffect, useState } from 'react' +import type { UrlObject } from 'url' + +import { AppRoutes } from '@/config/routes' +import { useCurrentChain } from '@/hooks/useChains' + +export const useShareSafeAppUrl = (appUrl: string): string => { + const router = useRouter() + const chain = useCurrentChain() + const [shareSafeAppUrl, setShareSafeAppUrl] = useState('') + + useEffect(() => { + if (typeof window === 'undefined') { + return + } + + const shareUrlObj: UrlObject = { + protocol: window.location.protocol, + host: window.location.host, + pathname: AppRoutes.share.safeApp, + query: { appUrl, chain: chain?.shortName }, + } + + setShareSafeAppUrl(resolveHref(router, shareUrlObj)) + }, [appUrl, chain?.shortName, router]) + + return shareSafeAppUrl +} diff --git a/src/components/safe-apps/utils.ts b/src/components/safe-apps/utils.ts index a977b373..f8421a94 100644 --- a/src/components/safe-apps/utils.ts +++ b/src/components/safe-apps/utils.ts @@ -98,3 +98,18 @@ export const filterInternalCategories = (categories: string[]): string[] => { const internalCategories = Object.values(SafeAppsTag) return categories.filter((tag) => !internalCategories.some((internalCategory) => tag === internalCategory)) } + +// Get unique tags from all apps +export const getUniqueTags = (apps: SafeAppData[]): string[] => { + // Get the list of categories from the safeAppsList + const tags = apps.reduce>((result, app) => { + app.tags.forEach((tag) => result.add(tag)) + return result + }, new Set()) + + // Filter out internal tags + const filteredTags = filterInternalCategories(Array.from(tags)) + + // Sort alphabetically + return filteredTags.sort() +} diff --git a/src/components/safe-messages/InfoBox/index.tsx b/src/components/safe-messages/InfoBox/index.tsx new file mode 100644 index 00000000..b644c68d --- /dev/null +++ b/src/components/safe-messages/InfoBox/index.tsx @@ -0,0 +1,32 @@ +import { type ReactElement, type ReactNode } from 'react' +import { Typography, SvgIcon, Divider } from '@mui/material' +import classNames from 'classnames' +import InfoIcon from '@/public/images/notifications/info.svg' +import css from './styles.module.css' + +const InfoBox = ({ + message, + children, + className, +}: { + message: string + children: ReactNode + className?: string +}): ReactElement => { + return ( +
+
+ +
+ + {message} + +
+
+ +
{children}
+
+ ) +} + +export default InfoBox diff --git a/src/components/safe-messages/InfoBox/styles.module.css b/src/components/safe-messages/InfoBox/styles.module.css new file mode 100644 index 00000000..2de47154 --- /dev/null +++ b/src/components/safe-messages/InfoBox/styles.module.css @@ -0,0 +1,30 @@ +.container { + background-color: var(--color-info-background); + padding: var(--space-2); + margin: var(--space-2) 0; + border-radius: 4px; + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.message { + display: flex; + align-items: flex-start; + gap: var(--space-1); +} + +.message button { + vertical-align: baseline; + text-decoration: underline; +} + +.message svg { + margin-top: 4px; +} + +.details { + margin-top: var(--space-1); + color: var(--color-primary-light); + word-break: break-word; +} diff --git a/src/components/safe-messages/Msg/index.tsx b/src/components/safe-messages/Msg/index.tsx index 695c5b81..6a06e483 100644 --- a/src/components/safe-messages/Msg/index.tsx +++ b/src/components/safe-messages/Msg/index.tsx @@ -5,6 +5,8 @@ import type { SafeMessage } from '@safe-global/safe-gateway-typescript-sdk' import css from './styles.module.css' +const MAX_ROWS = 10 + const Msg = ({ message }: { message: SafeMessage['message'] }): ReactElement => { const isTextMessage = typeof message === 'string' @@ -14,7 +16,7 @@ const Msg = ({ message }: { message: SafeMessage['message'] }): ReactElement => return ( { const wallet = useWallet() - const isWrongChain = useIsWrongChain() const isConfirmed = msg.status === SafeMessageStatus.CONFIRMED const safeMessage = useMemo(() => { return generateSafeMessageMessage(msg.message) @@ -106,7 +104,7 @@ const MsgDetails = ({ msg }: { msg: SafeMessage }): ReactElement => {
- {wallet && !isWrongChain && !isConfirmed && ( + {wallet && !isConfirmed && ( diff --git a/src/components/safe-messages/MsgModal/ConfirmationDialog.tsx b/src/components/safe-messages/MsgModal/ConfirmationDialog.tsx new file mode 100644 index 00000000..3c73abc0 --- /dev/null +++ b/src/components/safe-messages/MsgModal/ConfirmationDialog.tsx @@ -0,0 +1,31 @@ +import { Dialog, DialogTitle, DialogContent, DialogContentText, Typography, DialogActions, Button } from '@mui/material' + +export const ConfirmationDialog = ({ + open, + onCancel, + onClose, +}: { + open: boolean + onCancel: () => void + onClose: () => void +}) => ( + + Cancel message signing request + + + If you close this modal, the signing request will be aborted. + + + + + + + +) diff --git a/src/components/safe-messages/MsgModal/index.test.tsx b/src/components/safe-messages/MsgModal/index.test.tsx index 71e3751b..d44c40b4 100644 --- a/src/components/safe-messages/MsgModal/index.test.tsx +++ b/src/components/safe-messages/MsgModal/index.test.tsx @@ -1,6 +1,6 @@ import { hexlify, hexZeroPad, toUtf8Bytes } from 'ethers/lib/utils' -import { Web3Provider } from '@ethersproject/providers' -import type { SafeInfo, SafeMessage } from '@safe-global/safe-gateway-typescript-sdk' +import type { ChainInfo, SafeInfo, SafeMessage } from '@safe-global/safe-gateway-typescript-sdk' +import { SafeMessageListItemType } from '@safe-global/safe-gateway-typescript-sdk' import MsgModal from '@/components/safe-messages/MsgModal' import * as useIsWrongChainHook from '@/hooks/useIsWrongChain' @@ -8,17 +8,64 @@ import * as useIsSafeOwnerHook from '@/hooks/useIsSafeOwner' import * as useWalletHook from '@/hooks/wallets/useWallet' import * as useSafeInfoHook from '@/hooks/useSafeInfo' import * as useAsyncHook from '@/hooks/useAsync' +import * as useChainsHook from '@/hooks/useChains' +import * as useSafeMessages from '@/hooks/messages/useSafeMessages' import * as sender from '@/services/safe-messages/safeMsgSender' -import * as web3 from '@/hooks/wallets/web3' +import * as onboard from '@/hooks/wallets/useOnboard' import { render, act, fireEvent, waitFor } from '@/tests/test-utils' import type { ConnectedWallet } from '@/hooks/wallets/useOnboard' +import type { EIP1193Provider, WalletState, AppState, OnboardAPI } from '@web3-onboard/core' +import { generateSafeMessageHash } from '@/utils/safe-messages' jest.mock('@safe-global/safe-gateway-typescript-sdk', () => ({ ...jest.requireActual('@safe-global/safe-gateway-typescript-sdk'), getSafeMessage: jest.fn(), })) -const mockProvider: Web3Provider = new Web3Provider(jest.fn()) +let mockProvider = { + request: jest.fn, +} as unknown as EIP1193Provider + +const mockOnboardState = { + chains: [], + walletModules: [], + wallets: [ + { + label: 'Wallet 1', + icon: '', + provider: mockProvider, + chains: [{ id: '0x5' }], + accounts: [ + { + address: '0x1234567890123456789012345678901234567890', + ens: null, + balance: null, + }, + ], + }, + ] as WalletState[], + accountCenter: { + enabled: true, + }, +} as unknown as AppState + +const mockOnboard = { + connectWallet: jest.fn(), + disconnectWallet: jest.fn(), + setChain: jest.fn(), + state: { + select: (key: keyof AppState) => ({ + subscribe: (next: any) => { + next(mockOnboardState[key]) + + return { + unsubscribe: jest.fn(), + } + }, + }), + get: () => mockOnboardState, + }, +} as unknown as OnboardAPI describe('MsgModal', () => { beforeEach(() => { @@ -31,26 +78,15 @@ describe('MsgModal', () => { value: hexZeroPad('0x1', 20), }, chainId: '5', + threshold: 2, } as SafeInfo, safeAddress: hexZeroPad('0x1', 20), safeError: undefined, safeLoading: false, safeLoaded: true, })) - }) - - it('renders the message hash', () => { - const { getByText } = render( - , - ) - expect(getByText('0x123')).toBeInTheDocument() + jest.spyOn(useIsWrongChainHook, 'default').mockImplementation(() => false) }) describe('EIP-191 messages', () => { @@ -186,9 +222,8 @@ describe('MsgModal', () => { }) it('proposes a message if not already proposed', async () => { - jest.spyOn(useIsWrongChainHook, 'default').mockImplementation(() => false) jest.spyOn(useIsSafeOwnerHook, 'default').mockImplementation(() => true) - jest.spyOn(web3, 'useWeb3').mockReturnValue(mockProvider) + jest.spyOn(onboard, 'default').mockReturnValue(mockOnboard) jest.spyOn(useAsyncHook, 'default').mockReturnValue([undefined, new Error('SafeMessage not found'), false]) @@ -219,39 +254,56 @@ describe('MsgModal', () => { value: hexZeroPad('0x1', 20), }, chainId: '5', + threshold: 2, } as SafeInfo, message: 'Hello world!', - requestId: '123', safeAppId: 25, }), ) }) it('confirms the message if already proposed', async () => { - jest.spyOn(web3, 'useWeb3').mockReturnValue(mockProvider) - jest.spyOn(useIsWrongChainHook, 'default').mockImplementation(() => false) + jest.spyOn(onboard, 'default').mockReturnValue(mockOnboard) jest.spyOn(useIsSafeOwnerHook, 'default').mockImplementation(() => true) jest.spyOn(useWalletHook, 'default').mockImplementation( () => ({ - address: hexZeroPad('0x2', 20), + address: hexZeroPad('0x3', 20), } as ConnectedWallet), ) - jest - .spyOn(useAsyncHook, 'default') - .mockReturnValue([ - { confirmations: [] as SafeMessage['confirmations'] } as SafeMessage, - new Error('SafeMessage not found'), - false, - ]) + const messageText = 'Hello world!' + const messageHash = generateSafeMessageHash( + { + version: '1.3.0', + address: { + value: hexZeroPad('0x1', 20), + }, + chainId: '5', + } as SafeInfo, + messageText, + ) + const msg = { + type: SafeMessageListItemType.MESSAGE, + messageHash, + confirmations: [ + { + owner: { + value: hexZeroPad('0x2', 20), + }, + }, + ], + confirmationsRequired: 2, + confirmationsSubmitted: 1, + } as unknown as SafeMessage + + jest.spyOn(useSafeMessages, 'useSafeMessage').mockReturnValue(msg) const { getByText } = render( , @@ -265,6 +317,8 @@ describe('MsgModal', () => { const button = getByText('Sign') + expect(button).toBeEnabled() + await act(() => { fireEvent.click(button) }) @@ -277,15 +331,16 @@ describe('MsgModal', () => { value: hexZeroPad('0x1', 20), }, chainId: '5', + threshold: 2, } as SafeInfo, message: 'Hello world!', - requestId: '123', }), ) }) - it('displays an error if connected to the wrong chain', () => { - jest.spyOn(web3, 'useWeb3').mockReturnValue(undefined) + it('displays an error if no wallet is connected', () => { + jest.spyOn(useWalletHook, 'default').mockReturnValue(null) + jest.spyOn(useIsSafeOwnerHook, 'default').mockImplementation(() => false) const { getByText } = render( { }) it('displays an error if connected to the wrong chain', () => { - jest.spyOn(web3, 'useWeb3').mockReturnValue(mockProvider) + jest.spyOn(onboard, 'default').mockReturnValue(mockOnboard) + jest.spyOn(useIsSafeOwnerHook, 'default').mockImplementation(() => true) jest.spyOn(useIsWrongChainHook, 'default').mockImplementation(() => true) + jest.spyOn(useChainsHook, 'useCurrentChain').mockReturnValue({ chainName: 'Goerli' } as ChainInfo) const { getByText } = render( { />, ) - expect(getByText('Your wallet is connected to the wrong chain.')).toBeInTheDocument() + expect(getByText('Wallet network switch')).toBeInTheDocument() - expect(getByText('Sign')).toBeDisabled() + expect(getByText('Sign')).not.toBeDisabled() }) it('displays an error if not an owner', () => { - jest.spyOn(useIsWrongChainHook, 'default').mockImplementation(() => false) + jest.spyOn(onboard, 'default').mockReturnValue(mockOnboard) + jest.spyOn(useWalletHook, 'default').mockImplementation( + () => + ({ + address: hexZeroPad('0x7', 20), + } as ConnectedWallet), + ) jest.spyOn(useIsSafeOwnerHook, 'default').mockImplementation(() => false) const { getByText } = render( @@ -339,14 +402,14 @@ describe('MsgModal', () => { ) expect( - getByText("You are currently not an owner of this Safe and won't be able to confirm this message."), + getByText("You are currently not an owner of this Safe Account and won't be able to confirm this message."), ).toBeInTheDocument() expect(getByText('Sign')).toBeDisabled() }) - it('displays an error if the message has already been signed', async () => { - jest.spyOn(useIsWrongChainHook, 'default').mockImplementation(() => false) + it('displays a success message if the message has already been signed', async () => { + jest.spyOn(onboard, 'default').mockReturnValue(mockOnboard) jest.spyOn(useIsSafeOwnerHook, 'default').mockImplementation(() => true) jest.spyOn(useWalletHook, 'default').mockImplementation( () => @@ -354,29 +417,40 @@ describe('MsgModal', () => { address: hexZeroPad('0x2', 20), } as ConnectedWallet), ) - - jest.spyOn(useAsyncHook, 'default').mockReturnValue([ + const messageText = 'Hello world!' + const messageHash = generateSafeMessageHash( { - confirmations: [ - { - owner: { - value: hexZeroPad('0x2', 20), - }, + version: '1.3.0', + address: { + value: hexZeroPad('0x1', 20), + }, + chainId: '5', + } as SafeInfo, + messageText, + ) + const msg = { + type: SafeMessageListItemType.MESSAGE, + messageHash, + confirmations: [ + { + owner: { + value: hexZeroPad('0x2', 20), }, - ], - } as SafeMessage, - new Error('SafeMessage not found'), - false, - ]) + }, + ], + confirmationsRequired: 2, + confirmationsSubmitted: 1, + } as unknown as SafeMessage + + jest.spyOn(useSafeMessages, 'useSafeMessage').mockReturnValue(msg) const { getByText } = render( , ) @@ -388,7 +462,14 @@ describe('MsgModal', () => { }) it('displays an error if the message could not be proposed', async () => { - jest.spyOn(useIsWrongChainHook, 'default').mockImplementation(() => false) + jest.spyOn(onboard, 'default').mockReturnValue(mockOnboard) + jest.spyOn(useWalletHook, 'default').mockImplementation( + () => + ({ + address: hexZeroPad('0x3', 20), + } as ConnectedWallet), + ) + jest.spyOn(useSafeMessages, 'useSafeMessage').mockReturnValue(undefined) jest.spyOn(useIsSafeOwnerHook, 'default').mockImplementation(() => true) jest.spyOn(useAsyncHook, 'default').mockReturnValue([undefined, new Error('SafeMessage not found'), false]) @@ -409,22 +490,24 @@ describe('MsgModal', () => { ) const button = getByText('Sign') + expect(button).not.toBeDisabled() await act(() => { fireEvent.click(button) }) - expect(proposalSpy).toHaveBeenCalled() - await waitFor(() => { + expect(proposalSpy).toHaveBeenCalled() expect(getByText('Error confirming the message. Please try again.')).toBeInTheDocument() }) }) it('displays an error if the message could not be confirmed', async () => { - jest.spyOn(useIsWrongChainHook, 'default').mockImplementation(() => false) + jest.spyOn(onboard, 'default').mockReturnValue(mockOnboard) jest.spyOn(useIsSafeOwnerHook, 'default').mockImplementation(() => true) + jest.spyOn(useSafeMessages, 'useSafeMessage').mockReturnValue(undefined) + jest .spyOn(useAsyncHook, 'default') .mockReturnValue([ @@ -434,7 +517,7 @@ describe('MsgModal', () => { ]) const confirmationSpy = jest - .spyOn(sender, 'dispatchSafeMsgConfirmation') + .spyOn(sender, 'dispatchSafeMsgProposal') .mockImplementation(() => Promise.reject(new Error('Test error'))) const { getByText } = render( diff --git a/src/components/safe-messages/MsgModal/index.tsx b/src/components/safe-messages/MsgModal/index.tsx index c515952a..ab9ab83a 100644 --- a/src/components/safe-messages/MsgModal/index.tsx +++ b/src/components/safe-messages/MsgModal/index.tsx @@ -1,6 +1,7 @@ import { Grid, DialogActions, Button, Box, Typography, DialogContent, SvgIcon } from '@mui/material' -import { useCallback, useMemo, useState } from 'react' -import { getSafeMessage } from '@safe-global/safe-gateway-typescript-sdk' +import { useTheme } from '@mui/material/styles' +import { useCallback, useState } from 'react' +import { SafeMessageListItemType, SafeMessageStatus } from '@safe-global/safe-gateway-typescript-sdk' import type { ReactElement } from 'react' import type { SafeMessage } from '@safe-global/safe-gateway-typescript-sdk' import type { RequestId } from '@safe-global/safe-apps-sdk' @@ -9,26 +10,146 @@ import ModalDialog, { ModalDialogTitle } from '@/components/common/ModalDialog' import SafeAppIconCard from '@/components/safe-apps/SafeAppIconCard' import EthHashInfo from '@/components/common/EthHashInfo' import RequiredIcon from '@/public/images/messages/required.svg' -import { dispatchSafeMsgConfirmation, dispatchSafeMsgProposal } from '@/services/safe-messages/safeMsgSender' import useSafeInfo from '@/hooks/useSafeInfo' -import { generateSafeMessageHash, generateSafeMessageMessage } from '@/utils/safe-messages' -import { getDecodedMessage } from '@/components/safe-apps/utils' + import useIsSafeOwner from '@/hooks/useIsSafeOwner' -import useIsWrongChain from '@/hooks/useIsWrongChain' import ErrorMessage from '@/components/tx/ErrorMessage' -import useAsync from '@/hooks/useAsync' import useWallet from '@/hooks/wallets/useWallet' -import useSafeMessages from '@/hooks/useSafeMessages' -import { isSafeMessageListItem } from '@/utils/safe-message-guards' -import { useWeb3 } from '@/hooks/wallets/web3' +import { useSafeMessage } from '@/hooks/messages/useSafeMessages' +import useOnboard, { switchWallet } from '@/hooks/wallets/useOnboard' import txStepperCss from '@/components/tx/TxStepper/styles.module.css' import { DecodedMsg } from '../DecodedMsg' import CopyButton from '@/components/common/CopyButton' +import { WrongChainWarning } from '@/components/tx/WrongChainWarning' +import MsgSigners from '@/components/safe-messages/MsgSigners' +import { ConfirmationDialog } from './ConfirmationDialog' +import useDecodedSafeMessage from '@/hooks/messages/useDecodedSafeMessage' +import useSyncSafeMessageSigner from '@/hooks/messages/useSyncSafeMessageSigner' +import SuccessMessage from '@/components/tx/SuccessMessage' +import InfoBox from '../InfoBox' +import useHighlightHiddenTab from '@/hooks/useHighlightHiddenTab' const APP_LOGO_FALLBACK_IMAGE = '/images/apps/apps-icon.svg' const APP_NAME_FALLBACK = 'Sign message off-chain' +const createSkeletonMessage = (confirmationsRequired: number): SafeMessage => { + return { + confirmations: [], + confirmationsRequired, + confirmationsSubmitted: 0, + creationTimestamp: 0, + message: '', + logoUri: null, + messageHash: '', + modifiedTimestamp: 0, + name: null, + proposedBy: { + value: '', + }, + status: SafeMessageStatus.NEEDS_CONFIRMATION, + type: SafeMessageListItemType.MESSAGE, + } +} + +const MessageHashField = ({ label, hashValue }: { label: string; hashValue: string }) => ( + <> + + {label}: + + + + + +) + +const DialogHeader = ({ threshold }: { threshold: number }) => ( + <> + + + + + Confirm message + + + To sign this message, you need to collect {threshold} owner signatures of your Safe Account. + + +) + +const DialogTitle = ({ + onClose, + name, + logoUri, +}: { + onClose: () => void + name: string | null + logoUri: string | null +}) => { + const appName = name || APP_NAME_FALLBACK + const appLogo = logoUri || APP_LOGO_FALLBACK_IMAGE + return ( + + + + + + + {appName} + + + + + + ) +} + +const MessageDialogError = ({ isOwner, submitError }: { isOwner: boolean; submitError: Error | undefined }) => { + const wallet = useWallet() + const onboard = useOnboard() + + const errorMessage = + !wallet || !onboard + ? 'No wallet is connected.' + : !isOwner + ? "You are currently not an owner of this Safe Account and won't be able to confirm this message." + : submitError + ? 'Error confirming the message. Please try again.' + : null + + if (errorMessage) { + return {errorMessage} + } + return null +} + +const AlreadySignedByOwnerMessage = ({ hasSigned }: { hasSigned: boolean }) => { + const onboard = useOnboard() + + const handleSwitchWallet = () => { + if (onboard) { + switchWallet(onboard) + } + } + if (!hasSigned) { + return null + } + return ( + + + + Your connected wallet has already signed this message. + + + + + + + ) +} + type BaseProps = { onClose: () => void } & Pick @@ -36,14 +157,12 @@ type BaseProps = { // Custom Safe Apps do not have a `safeAppId` type ProposeProps = BaseProps & { safeAppId?: number - messageHash?: never requestId: RequestId } // A proposed message does not return the `safeAppId` but the `logoUri` and `name` of the Safe App that proposed it type ConfirmProps = BaseProps & { safeAppId?: never - messageHash: string requestId?: RequestId } @@ -52,146 +171,94 @@ const MsgModal = ({ logoUri, name, message, - messageHash, safeAppId, requestId, }: ProposeProps | ConfirmProps): ReactElement => { // Hooks & variables - const [submitError, setSubmitError] = useState() - - const web3 = useWeb3() + const [showCloseTooltip, setShowCloseTooltip] = useState(false) + const { palette } = useTheme() const { safe } = useSafeInfo() - const isWrongChain = useIsWrongChain() const isOwner = useIsSafeOwner() const wallet = useWallet() - const messages = useSafeMessages() - // Decode message if UTF-8 encoded - const decodedMessage = useMemo(() => { - return typeof message === 'string' ? getDecodedMessage(message) : message - }, [message]) + const { decodedMessage, safeMessageMessage, safeMessageHash } = useDecodedSafeMessage(message, safe) + const ongoingMessage = useSafeMessage(safeMessageHash) + useHighlightHiddenTab() - // Get `SafeMessage` message - const safeMessageMessage = useMemo(() => { - return generateSafeMessageMessage(decodedMessage) - }, [decodedMessage]) + const decodedMessageAsString = + typeof decodedMessage === 'string' ? decodedMessage : JSON.stringify(decodedMessage, null, 2) - // Get `SafeMessage` hash - const safeMessageHash = useMemo(() => { - return messageHash ?? generateSafeMessageHash(safe, decodedMessage) - }, [messageHash, safe, decodedMessage]) + const hasSigned = !!ongoingMessage?.confirmations.some(({ owner }) => owner.value === wallet?.address) - // Get already proposed message - const [alreadyProposedMessage] = useAsync>(() => { - const localMessage = messages.page?.results - .filter(isSafeMessageListItem) - .find((message) => message.messageHash === messageHash) + const isDisabled = !isOwner || hasSigned - return localMessage ? Promise.resolve(localMessage) : getSafeMessage(safe.chainId, safeMessageHash) - }, [safe.chainId, messageHash, safeMessageHash]) + const { onSign, submitError } = useSyncSafeMessageSigner( + ongoingMessage, + decodedMessage, + safeMessageHash, + requestId, + safeAppId, + onClose, + ) - const hasSigned = !!alreadyProposedMessage?.confirmations.some(({ owner }) => owner.value === wallet?.address) + const handleClose = useCallback(() => { + if (requestId && (!ongoingMessage || ongoingMessage.status === SafeMessageStatus.NEEDS_CONFIRMATION)) { + // If we are in a Safe app modal we want to keep the modal open + setShowCloseTooltip(true) + } else { + onClose() + } + }, [onClose, ongoingMessage, requestId]) - const isDisabled = isWrongChain || !isOwner || hasSigned || !web3 + return ( + <> + +
+ - const onSign = useCallback(async () => { - // Error is shown when no wallet is connected, this appeases TypeScript - if (!web3) { - return - } + + - setSubmitError(undefined) + + Message: + + - const signer = web3.getSigner() + + - try { - if (requestId && !alreadyProposedMessage) { - await dispatchSafeMsgProposal({ signer, safe, message: decodedMessage, requestId, safeAppId }) - } else { - await dispatchSafeMsgConfirmation({ signer, safe, message: decodedMessage, requestId }) - } + - onClose() - } catch (e) { - setSubmitError(e as Error) - } - }, [alreadyProposedMessage, decodedMessage, onClose, requestId, safe, safeAppId, web3]) + + + - return ( - -
- - - - - - - {name || APP_NAME_FALLBACK} - - - - - - - - - - - - Confirm message - - - This action will confirm the message and add your confirmation to the prepared signature. - - - Message:{' '} - - - - - SafeMessage: - - - - - - - SafeMessage hash: - - - - - - {!web3 ? ( - No wallet is connected. - ) : isWrongChain ? ( - Your wallet is connected to the wrong chain. - ) : !isOwner ? ( - - You are currently not an owner of this Safe and won't be able to confirm this message. - - ) : hasSigned ? ( - Your connected wallet has already signed this message. - ) : submitError ? ( - Error confirming the message. Please try again. - ) : null} - - - - - - -
-
+ + + +
+ + + + + +
+
+ setShowCloseTooltip(false)} onClose={onClose} /> + ) } diff --git a/src/components/safe-messages/MsgSigners/MsgSigners.test.tsx b/src/components/safe-messages/MsgSigners/MsgSigners.test.tsx new file mode 100644 index 00000000..258618a5 --- /dev/null +++ b/src/components/safe-messages/MsgSigners/MsgSigners.test.tsx @@ -0,0 +1,79 @@ +import { render } from '@/tests/test-utils' +import { SafeMessageStatus, SafeMessageListItemType, type SafeMessage } from '@safe-global/safe-gateway-typescript-sdk' +import { hexZeroPad } from 'ethers/lib/utils' +import MsgSigners from '.' + +describe('MsgSigners', () => { + it('Message with more confirmations submitted than required', () => { + const mockMessage: SafeMessage = { + confirmations: [ + { + owner: { + value: hexZeroPad('0x1', 20), + }, + signature: '0x123', + }, + { + owner: { + value: hexZeroPad('0x2', 20), + }, + signature: '0x456', + }, + ], + confirmationsRequired: 1, + confirmationsSubmitted: 2, + creationTimestamp: 0, + message: '', + logoUri: null, + messageHash: '', + modifiedTimestamp: 0, + name: null, + proposedBy: { + value: '', + }, + status: SafeMessageStatus.NEEDS_CONFIRMATION, + type: SafeMessageListItemType.MESSAGE, + } + + const result = render() + + expect(result.baseElement).toHaveTextContent('0x0000...0001') + expect(result.baseElement).toHaveTextContent('0x0000...0002') + expect(result.baseElement).toHaveTextContent('2 of 1') + }) + + it('should show missing signatures if prop is enabled', () => { + const mockMessage: SafeMessage = { + confirmations: [ + { + owner: { + value: hexZeroPad('0x1', 20), + }, + signature: '0x123', + }, + ], + confirmationsRequired: 5, + confirmationsSubmitted: 1, + creationTimestamp: 0, + message: '', + logoUri: null, + messageHash: '', + modifiedTimestamp: 0, + name: null, + proposedBy: { + value: '', + }, + status: SafeMessageStatus.NEEDS_CONFIRMATION, + type: SafeMessageListItemType.MESSAGE, + } + + const result = render() + + expect(result.baseElement).toHaveTextContent('0x0000...0001') + expect(result.baseElement).toHaveTextContent('1 of 5') + expect(result.baseElement).toHaveTextContent('Confirmation #2') + expect(result.baseElement).toHaveTextContent('Confirmation #3') + expect(result.baseElement).toHaveTextContent('Confirmation #4') + expect(result.baseElement).toHaveTextContent('Confirmation #5') + }) +}) diff --git a/src/components/safe-messages/MsgSigners/index.tsx b/src/components/safe-messages/MsgSigners/index.tsx index 831726bc..045a1602 100644 --- a/src/components/safe-messages/MsgSigners/index.tsx +++ b/src/components/safe-messages/MsgSigners/index.tsx @@ -1,7 +1,8 @@ import { useState, type ReactElement } from 'react' -import { Box, Link, List, ListItem, ListItemIcon, ListItemText, SvgIcon } from '@mui/material' +import { Box, Link, List, ListItem, ListItemIcon, ListItemText, Skeleton, SvgIcon, Typography } from '@mui/material' import { SafeMessageStatus } from '@safe-global/safe-gateway-typescript-sdk' import type { SafeMessage } from '@safe-global/safe-gateway-typescript-sdk' +import CircleOutlinedIcon from '@mui/icons-material/CircleOutlined' import CreatedIcon from '@/public/images/messages/created.svg' import SignedIcon from '@/public/images/messages/signed.svg' @@ -44,7 +45,17 @@ const shouldHideConfirmations = (msg: SafeMessage): boolean => { return isConfirmed || msg.confirmations.length > 3 } -export const MsgSigners = ({ msg }: { msg: SafeMessage }): ReactElement => { +export const MsgSigners = ({ + msg, + showOnlyConfirmations = false, + showMissingSignatures = false, + backgroundColor, +}: { + msg: SafeMessage + showOnlyConfirmations?: boolean + showMissingSignatures?: boolean + backgroundColor?: string +}): ReactElement => { const [hideConfirmations, setHideConfirmations] = useState(shouldHideConfirmations(msg)) const toggleHide = () => { @@ -53,16 +64,20 @@ export const MsgSigners = ({ msg }: { msg: SafeMessage }): ReactElement => { const { confirmations, confirmationsRequired, confirmationsSubmitted } = msg + const missingConfirmations = [...new Array(Math.max(0, confirmationsRequired - confirmationsSubmitted))] + const isConfirmed = msg.status === SafeMessageStatus.CONFIRMED return ( - - - - - Created - + {!showOnlyConfirmations && ( + + + + + Created + + )} @@ -77,7 +92,7 @@ export const MsgSigners = ({ msg }: { msg: SafeMessage }): ReactElement => { {!hideConfirmations && confirmations.map(({ owner }) => ( - + @@ -85,9 +100,9 @@ export const MsgSigners = ({ msg }: { msg: SafeMessage }): ReactElement => { ))} - {confirmations.length > 0 && ( + {!showOnlyConfirmations && confirmations.length > 0 && ( - + @@ -97,9 +112,25 @@ export const MsgSigners = ({ msg }: { msg: SafeMessage }): ReactElement => { )} + {showMissingSignatures && + missingConfirmations.map((_, idx) => ( + + + + + + + + + Confirmation #{idx + 1 + confirmationsSubmitted} + + + + + ))} {isConfirmed && ( - + Confirmed diff --git a/src/components/safe-messages/MsgSigners/styles.module.css b/src/components/safe-messages/MsgSigners/styles.module.css index 1c953ca2..7a6581ed 100644 --- a/src/components/safe-messages/MsgSigners/styles.module.css +++ b/src/components/safe-messages/MsgSigners/styles.module.css @@ -35,5 +35,4 @@ justify-content: center; min-width: 32px; padding: var(--space-1) 0; - background-color: var(--color-background-paper); } diff --git a/src/components/safe-messages/MsgSummary/index.tsx b/src/components/safe-messages/MsgSummary/index.tsx index d93e540d..21651cd0 100644 --- a/src/components/safe-messages/MsgSummary/index.tsx +++ b/src/components/safe-messages/MsgSummary/index.tsx @@ -6,11 +6,10 @@ import type { SafeMessage } from '@safe-global/safe-gateway-typescript-sdk' import DateTime from '@/components/common/DateTime' import useWallet from '@/hooks/wallets/useWallet' -import useIsWrongChain from '@/hooks/useIsWrongChain' import MsgType from '@/components/safe-messages/MsgType' import SignMsgButton from '@/components/safe-messages/SignMsgButton' -import useSafeMessageStatus from '@/hooks/useSafeMessageStatus' -import useIsSafeMessagePending from '@/hooks/useIsSafeMessagePending' +import useSafeMessageStatus from '@/hooks/messages/useSafeMessageStatus' +import useIsSafeMessagePending from '@/hooks/messages/useIsSafeMessagePending' import TxConfirmations from '@/components/transactions/TxConfirmations' import classNames from 'classnames' @@ -30,7 +29,6 @@ const getStatusColor = (value: SafeMessageStatus, palette: Palette) => { const MsgSummary = ({ msg }: { msg: SafeMessage }): ReactElement => { const { confirmationsSubmitted, confirmationsRequired } = msg const wallet = useWallet() - const isWrongChain = useIsWrongChain() const txStatusLabel = useSafeMessageStatus(msg) const isPending = useIsSafeMessagePending(msg.messageHash) const isConfirmed = msg.status === SafeMessageStatus.CONFIRMED @@ -58,7 +56,7 @@ const MsgSummary = ({ msg }: { msg: SafeMessage }): ReactElement => { )} - {wallet && !isWrongChain && !isConfirmed && ( + {wallet && !isConfirmed && ( diff --git a/src/components/safe-messages/PaginatedMsgs/index.tsx b/src/components/safe-messages/PaginatedMsgs/index.tsx index 177915e8..b5722230 100644 --- a/src/components/safe-messages/PaginatedMsgs/index.tsx +++ b/src/components/safe-messages/PaginatedMsgs/index.tsx @@ -1,16 +1,18 @@ import { Box } from '@mui/material' import { Typography, Link, SvgIcon } from '@mui/material' -import { useState } from 'react' +import { useEffect, useState } from 'react' import type { ReactElement } from 'react' import ErrorMessage from '@/components/tx/ErrorMessage' -import useSafeMessages from '@/hooks/useSafeMessages' +import useSafeMessages from '@/hooks/messages/useSafeMessages' import LinkIcon from '@/public/images/common/link.svg' import NoMessagesIcon from '@/public/images/messages/no-messages.svg' import InfiniteScroll from '@/components/common/InfiniteScroll' import PagePlaceholder from '@/components/common/PagePlaceholder' import MsgList from '@/components/safe-messages/MsgList' import SkeletonTxList from '@/components/common/PaginatedTxns/SkeletonTxList' +import { HelpCenterArticle } from '@/config/constants' +import useSafeInfo from '@/hooks/useSafeInfo' const NoMessages = (): ReactElement => { return ( @@ -19,12 +21,11 @@ const NoMessages = (): ReactElement => { text={ Some applications allow you to interact with them via off-chain contract signatures (“messages“) - that you can generate with your Safe. + that you can generate with your Safe Account. } > - {/* TODO: Add link to help article */} - + Learn more about off-chain messages{' '} @@ -62,12 +63,18 @@ const MsgPage = ({ const PaginatedMsgs = (): ReactElement => { const [pages, setPages] = useState(['']) + const { safeAddress, safe } = useSafeInfo() // Trigger the next page load const onNextPage = (pageUrl: string) => { setPages((prev) => prev.concat(pageUrl)) } + // Reset the pages when the Safe Account changes + useEffect(() => { + setPages(['']) + }, [safe.chainId, safeAddress]) + return ( {pages.map((pageUrl, index) => ( diff --git a/src/components/safe-messages/SignMsgButton/index.tsx b/src/components/safe-messages/SignMsgButton/index.tsx index 612aa54e..29d46336 100644 --- a/src/components/safe-messages/SignMsgButton/index.tsx +++ b/src/components/safe-messages/SignMsgButton/index.tsx @@ -7,8 +7,8 @@ import type { SafeMessage } from '@safe-global/safe-gateway-typescript-sdk' import useWallet from '@/hooks/wallets/useWallet' import Track from '@/components/common/Track' import { MESSAGE_EVENTS } from '@/services/analytics/events/txList' -import useIsSafeMessageSignableBy from '@/hooks/useIsSafeMessageSignableBy' -import useIsSafeMessagePending from '@/hooks/useIsSafeMessagePending' +import useIsSafeMessageSignableBy from '@/hooks/messages/useIsSafeMessageSignableBy' +import useIsSafeMessagePending from '@/hooks/messages/useIsSafeMessagePending' import MsgModal from '@/components/safe-messages/MsgModal' const SignMsgButton = ({ msg, compact = false }: { msg: SafeMessage; compact?: boolean }): ReactElement => { diff --git a/src/components/settings/ContractVersion/UpdateSafeDialog.tsx b/src/components/settings/ContractVersion/UpdateSafeDialog.tsx index 239f09b5..b424aaf1 100644 --- a/src/components/settings/ContractVersion/UpdateSafeDialog.tsx +++ b/src/components/settings/ContractVersion/UpdateSafeDialog.tsx @@ -1,11 +1,10 @@ -import { Box, Button, Typography } from '@mui/material' +import { Button, Typography } from '@mui/material' import { useState } from 'react' import { LATEST_SAFE_VERSION } from '@/config/constants' import TxModal from '@/components/tx/TxModal' -import useTxSender from '@/hooks/useTxSender' import useAsync from '@/hooks/useAsync' import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' @@ -16,10 +15,12 @@ import { createUpdateSafeTxs } from '@/services/tx/safeUpdateParams' import useSafeInfo from '@/hooks/useSafeInfo' import { useCurrentChain } from '@/hooks/useChains' import ExternalLink from '@/components/common/ExternalLink' +import { createMultiSendCallOnlyTx } from '@/services/tx/tx-sender' +import CheckWallet from '@/components/common/CheckWallet' const UpdateSafeSteps: TxStepperProps['steps'] = [ { - label: 'Update Safe version', + label: 'Update Safe Account version', render: (_, onSubmit) => , }, ] @@ -30,28 +31,29 @@ const UpdateSafeDialog = () => { const handleClose = () => setOpen(false) return ( - -
- -
+ <> + + {(isOk) => ( + + )} + {open && } -
+ ) } const ReviewUpdateSafeStep = ({ onSubmit }: { onSubmit: () => void }) => { const { safe, safeLoaded } = useSafeInfo() const chain = useCurrentChain() - const { createMultiSendCallOnlyTx } = useTxSender() const [safeTx, safeTxError] = useAsync(() => { if (!chain || !safeLoaded) return const txs = createUpdateSafeTxs(safe, chain) return createMultiSendCallOnlyTx(txs) - }, [chain, safe, safeLoaded, createMultiSendCallOnlyTx]) + }, [chain, safe, safeLoaded]) return ( @@ -62,13 +64,13 @@ const ReviewUpdateSafeStep = ({ onSubmit }: { onSubmit: () => void }) => { To check details about updates added by this smart contract version please visit{' '} - latest Safe contracts changelog + latest Safe Account contracts changelog You will need to confirm this update just like any other transaction. This means other owners will have to - confirm the update in case more than one confirmation is required for this Safe. + confirm the update in case more than one confirmation is required for this Safe Account. diff --git a/src/components/settings/ContractVersion/index.tsx b/src/components/settings/ContractVersion/index.tsx index 5082dd3d..491d1814 100644 --- a/src/components/settings/ContractVersion/index.tsx +++ b/src/components/settings/ContractVersion/index.tsx @@ -1,5 +1,5 @@ import { useMemo } from 'react' -import { SvgIcon, Typography } from '@mui/material' +import { Box, SvgIcon, Typography, Alert, AlertTitle, Skeleton } from '@mui/material' import { ImplementationVersionState } from '@safe-global/safe-gateway-typescript-sdk' import { LATEST_SAFE_VERSION } from '@/config/constants' import { sameAddress } from '@/utils/addresses' @@ -11,11 +11,9 @@ import InfoIcon from '@/public/images/notifications/info.svg' import UpdateSafeDialog from './UpdateSafeDialog' import ExternalLink from '@/components/common/ExternalLink' -import Tooltip from '@mui/material/Tooltip' - -export const ContractVersion = ({ isGranted }: { isGranted: boolean }) => { +export const ContractVersion = () => { const [masterCopies] = useMasterCopies() - const { safe } = useSafeInfo() + const { safe, safeLoaded } = useSafeInfo() const masterCopyAddress = safe.implementation.value const safeMasterCopy: MasterCopy | undefined = useMemo(() => { @@ -23,58 +21,39 @@ export const ContractVersion = ({ isGranted }: { isGranted: boolean }) => { }, [masterCopies, masterCopyAddress]) const needsUpdate = safe.implementationVersionState === ImplementationVersionState.OUTDATED - const latestMasterContractVersion = LATEST_SAFE_VERSION const showUpdateDialog = safeMasterCopy?.deployer === MasterCopyDeployer.GNOSIS && needsUpdate - const getSafeVersionUpdate = () => { - return showUpdateDialog ? ` (there's a newer version: ${latestMasterContractVersion})` : '' - } return ( -
+ <> Contract version - {safe.version ? ( - - {safe.version} - {getSafeVersionUpdate()} - - ) : ( - - Unsupported contract - - )} -
- {needsUpdate ? ( - - Why should I upgrade? - - - - - - - ) : ( - - Latest version - - )} -
- {showUpdateDialog && isGranted && } -
+ + {safeLoaded ? safe.version ? safe.version : 'Unsupported contract' : } + + + {safeLoaded ? ( + showUpdateDialog ? ( + } + > + New version is available: {LATEST_SAFE_VERSION} + + Update now to take advantage of new features and the highest security standards available. You will need + to confirm this update just like any other transaction.{' '} + GitHub + + + + ) : ( + + Latest version + + ) + ) : null} + + ) } diff --git a/src/components/settings/DataManagement/FileListCard.tsx b/src/components/settings/DataManagement/FileListCard.tsx new file mode 100644 index 00000000..984d373b --- /dev/null +++ b/src/components/settings/DataManagement/FileListCard.tsx @@ -0,0 +1,174 @@ +import { Box, Card, CardContent, CardHeader, List, ListItem, ListItemIcon, ListItemText, SvgIcon } from '@mui/material' +import type { ListItemTextProps } from '@mui/material' +import type { CardHeaderProps } from '@mui/material' +import type { ReactElement } from 'react' + +import FileIcon from '@/public/images/settings/data/file.svg' +import useChains from '@/hooks/useChains' +import { ImportErrors } from '@/components/settings/DataManagement/useGlobalImportFileParser' +import type { AddedSafesState } from '@/store/addedSafesSlice' +import type { AddressBookState } from '@/store/addressBookSlice' +import type { SafeAppsState } from '@/store/safeAppsSlice' +import type { SettingsState } from '@/store/settingsSlice' +import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' + +import css from './styles.module.css' + +const getItemSecondaryText = ( + chains: ChainInfo[], + data: AddedSafesState | AddressBookState = {}, + singular: string, + plural: string, +): ReactElement => { + return ( + + {Object.keys(data).map((chainId) => { + const count = Object.keys(data[chainId] ?? {}).length + + if (count === 0) { + return null + } + + const chain = chains.find((chain) => chain.chainId === chainId) + + return ( + + + {chain?.chainName}: {count} {count === 1 ? singular : plural} + + ) + })} + + ) +} + +type Data = { + addedSafes?: AddedSafesState + addressBook?: AddressBookState + settings?: SettingsState + safeApps?: SafeAppsState + error?: string +} + +type ListProps = Data & { + showPreview?: boolean +} + +type ItemProps = ListProps & { chains: ChainInfo[] } + +const getItems = ({ + addedSafes, + addressBook, + settings, + safeApps, + error, + chains, + showPreview = false, +}: ItemProps): Array => { + if (error) { + return [{ primary: <>{error} }] + } + + const addedSafeChainAmount = Object.keys(addedSafes || {}).length + const addressBookChainAmount = Object.keys(addressBook || {}).length + + const items: Array = [] + + if (addedSafeChainAmount > 0) { + const addedSafesPreview: ListItemTextProps = { + primary: ( + <> + Added Safe Accounts on {addedSafeChainAmount} {addedSafeChainAmount === 1 ? 'chain' : 'chains'} + + ), + secondary: showPreview ? getItemSecondaryText(chains, addedSafes, 'Safe', 'Safes') : undefined, + } + + items.push(addedSafesPreview) + } + + if (addressBookChainAmount > 0) { + const addressBookPreview: ListItemTextProps = { + primary: ( + <> + Address book for {addressBookChainAmount} {addressBookChainAmount === 1 ? 'chain' : 'chains'} + + ), + secondary: showPreview ? getItemSecondaryText(chains, addressBook, 'contact', 'contacts') : undefined, + } + + items.push(addressBookPreview) + } + + if (settings) { + const settingsPreview: ListItemTextProps = { + primary: ( + <> + Settings (appearance, currency, hidden tokens and custom environment variables) + + ), + } + + items.push(settingsPreview) + } + + const hasBookmarkedSafeApps = Object.values(safeApps || {}).some((chainId) => chainId.pinned?.length > 0) + if (hasBookmarkedSafeApps) { + const safeAppsPreview: ListItemTextProps = { + primary: ( + <> + Bookmarked Safe Apps + + ), + } + + items.push(safeAppsPreview) + } + + if (items.length === 0) { + return [{ primary: <>{ImportErrors.NO_IMPORT_DATA_FOUND} }] + } + + return items +} + +type Props = ListProps & CardHeaderProps + +export const FileListCard = ({ + addedSafes, + addressBook, + settings, + safeApps, + error, + showPreview = false, + ...cardHeaderProps +}: Props): ReactElement => { + const chains = useChains() + const items = getItems({ addedSafes, addressBook, settings, safeApps, error, chains: chains.configs, showPreview }) + + return ( + + + + + {items.map((item, i) => ( + + + + + cannot appear as a descendant of

+ secondaryTypographyProps={{ component: 'div' }} + /> + + ))} + + + + ) +} diff --git a/src/components/settings/DataManagement/ImportDialog.tsx b/src/components/settings/DataManagement/ImportDialog.tsx new file mode 100644 index 00000000..d9dea29b --- /dev/null +++ b/src/components/settings/DataManagement/ImportDialog.tsx @@ -0,0 +1,128 @@ +import { DialogContent, Alert, AlertTitle, DialogActions, Button, Box, SvgIcon } from '@mui/material' +import type { ReactElement, Dispatch, SetStateAction } from 'react' + +import ModalDialog from '@/components/common/ModalDialog' +import { useAppDispatch } from '@/store' +import { trackEvent, SETTINGS_EVENTS } from '@/services/analytics' +import { addedSafesSlice } from '@/store/addedSafesSlice' +import { addressBookSlice } from '@/store/addressBookSlice' +import { safeAppsSlice } from '@/store/safeAppsSlice' +import { settingsSlice } from '@/store/settingsSlice' +import { FileListCard } from '@/components/settings/DataManagement/FileListCard' +import { useGlobalImportJsonParser } from '@/components/settings/DataManagement/useGlobalImportFileParser' +import FileIcon from '@/public/images/settings/data/file.svg' +import { ImportFileUpload } from '@/components/settings/DataManagement/ImportFileUpload' +import { showNotification } from '@/store/notificationsSlice' + +import css from './styles.module.css' + +export const ImportDialog = ({ + onClose, + fileName = '', + setFileName, + jsonData = '', + setJsonData, +}: { + onClose?: () => void + fileName: string | undefined + setFileName: Dispatch> + jsonData: string | undefined + setJsonData: Dispatch> +}): ReactElement => { + const dispatch = useAppDispatch() + const { addedSafes, addedSafesCount, addressBook, addressBookEntriesCount, settings, safeApps, error } = + useGlobalImportJsonParser(jsonData) + + const isDisabled = (!addedSafes && !addressBook && !settings && !safeApps) || !!error + + const handleClose = () => { + setFileName(undefined) + setJsonData(undefined) + onClose?.() + } + + const handleImport = () => { + if (addressBook) { + dispatch(addressBookSlice.actions.setAddressBook(addressBook)) + trackEvent({ + ...SETTINGS_EVENTS.DATA.IMPORT_ADDRESS_BOOK, + label: addressBookEntriesCount, + }) + } + if (addedSafes) { + dispatch(addedSafesSlice.actions.setAddedSafes(addedSafes)) + trackEvent({ + ...SETTINGS_EVENTS.DATA.IMPORT_ADDED_SAFES, + label: addedSafesCount, + }) + } + + if (settings) { + dispatch(settingsSlice.actions.setSettings(settings)) + trackEvent(SETTINGS_EVENTS.DATA.IMPORT_SETTINGS) + } + + if (safeApps) { + dispatch(safeAppsSlice.actions.setSafeApps(safeApps)) + trackEvent(SETTINGS_EVENTS.DATA.IMPORT_SAFE_APPS) + } + + dispatch( + showNotification({ + variant: 'success', + groupKey: 'global-import-success', + message: 'Successfully imported data', + }), + ) + + handleClose() + } + + return ( + + + {!jsonData || !fileName ? ( + + + + ) : ( + <> + `${shape.borderRadius}px` }}> + + + } + title={{fileName}} + className={css.header} + addedSafes={addedSafes} + addressBook={addressBook} + settings={settings} + safeApps={safeApps} + error={error} + showPreview + /> + {!isDisabled && ( + + Overwrite your current data? + This action will overwrite your currently added Safe Accounts, address book and settings with those from + the imported file. + + )} + + )} + + + + + + + ) +} diff --git a/src/components/settings/DataManagement/ImportFileUpload.tsx b/src/components/settings/DataManagement/ImportFileUpload.tsx new file mode 100644 index 00000000..3f4bc5a7 --- /dev/null +++ b/src/components/settings/DataManagement/ImportFileUpload.tsx @@ -0,0 +1,81 @@ +import { useDropzone } from 'react-dropzone' +import { Typography, SvgIcon } from '@mui/material' +import { useCallback } from 'react' +import type { Dispatch, SetStateAction } from 'react' + +import FileUpload, { FileTypes } from '@/components/common/FileUpload' +import InfoIcon from '@/public/images/notifications/info.svg' + +const AcceptedMimeTypes = { + 'application/json': ['.json'], +} + +export const ImportFileUpload = ({ + setFileName, + setJsonData, +}: { + setFileName: Dispatch> + setJsonData: Dispatch> +}) => { + const onDrop = useCallback( + (acceptedFiles: File[]) => { + if (acceptedFiles.length === 0) { + return + } + const file = acceptedFiles[0] + const reader = new FileReader() + reader.onload = (event) => { + if (!event.target) { + return + } + if (typeof event.target.result !== 'string') { + return + } + setFileName(file.name) + setJsonData(event.target.result) + } + reader.readAsText(file) + }, + [setFileName, setJsonData], + ) + + const { getRootProps, getInputProps, isDragActive, isDragReject } = useDropzone({ + maxFiles: 1, + onDrop, + accept: AcceptedMimeTypes, + }) + + const onRemove = () => { + setFileName(undefined) + setJsonData(undefined) + } + + return ( + <> + Import {'Evmos Safe'} data by clicking or dragging a file below. + + ({ ...getRootProps(), height: '228px' })} + getInputProps={getInputProps} + isDragActive={isDragActive} + isDragReject={isDragReject} + onRemove={onRemove} + /> + + + + Only JSON files exported from a {'Evmos Safe'} can be imported. + + + ) +} diff --git a/src/components/settings/ImportAllDialog/__tests__/useGlobalImportFileParser.test.ts b/src/components/settings/DataManagement/__tests__/useGlobalImportFileParser.test.ts similarity index 54% rename from src/components/settings/ImportAllDialog/__tests__/useGlobalImportFileParser.test.ts rename to src/components/settings/DataManagement/__tests__/useGlobalImportFileParser.test.ts index 986fa460..f0915736 100644 --- a/src/components/settings/ImportAllDialog/__tests__/useGlobalImportFileParser.test.ts +++ b/src/components/settings/DataManagement/__tests__/useGlobalImportFileParser.test.ts @@ -1,5 +1,52 @@ import { renderHook } from '@/tests/test-utils' -import { ImportErrors, useGlobalImportJsonParser } from '../useGlobalImportFileParser' +import { ImportErrors, useGlobalImportJsonParser, _filterValidAbEntries } from '../useGlobalImportFileParser' + +describe('filterValidAbEntries', () => { + it('it should return undefined if no address book is provided', () => { + const ab = _filterValidAbEntries() + + expect(ab).toBeUndefined() + }) + + it('it should return valid address books as is', () => { + const ab = _filterValidAbEntries({ 1: { '0xAecDFD3A19f777F0c03e6bf99AAfB59937d6467b': 'name' } }) + + expect(ab).toStrictEqual({ 1: { '0xAecDFD3A19f777F0c03e6bf99AAfB59937d6467b': 'name' } }) + }) + + it('it should filter entries with invalid addresses', () => { + const ab = _filterValidAbEntries({ + 1: { '0xAecDFD3A19f777F0c03e6bf99AAfB59937d6467b': 'name', invalidAddress: 'name2' }, + 2: { '0XAECDFD3A19F777F0C03E6BF99AAFB59937D6467B': 'name3' }, + }) + + expect(ab).toStrictEqual({ 1: { '0xAecDFD3A19f777F0c03e6bf99AAfB59937d6467b': 'name' } }) + }) + + it('it should filter entries with invalid names', () => { + const ab = _filterValidAbEntries({ + 1: { + '0xAecDFD3A19f777F0c03e6bf99AAfB59937d6467b': '', + '0x3819b800c67Be64029C1393c8b2e0d0d627dADE2': ' ', + '0x7cB6E6Cbc845e79d9CA05F6577141DA36ad398f5': 'name', + }, + }) + + expect(ab).toStrictEqual({ 1: { '0x7cB6E6Cbc845e79d9CA05F6577141DA36ad398f5': 'name' } }) + }) + + it('it should remove empty chain address books pre-/post-validation', () => { + // Pre-validation + const ab1 = _filterValidAbEntries({ 1: { '0xAecDFD3A19f777F0c03e6bf99AAfB59937d6467b': 'name' }, 2: {} }) + + expect(ab1).toStrictEqual({ 1: { '0xAecDFD3A19f777F0c03e6bf99AAfB59937d6467b': 'name' } }) + + // Post-validation + const ab2 = _filterValidAbEntries({ 1: { invalidAddress: 'name' }, 2: {} }) + + expect(ab2).toStrictEqual({}) + }) +}) describe('useGlobalImportFileParser', () => { it('should return undefined values for undefined json', () => { @@ -9,28 +56,38 @@ describe('useGlobalImportFileParser', () => { addressBook: undefined, addressBookEntriesCount: 0, addedSafesCount: 0, + settings: undefined, + safeApps: undefined, + session: undefined, + error: undefined, }) }) it('should return undefined values and error for empty json', () => { - const { result } = renderHook(() => useGlobalImportJsonParser('{ "version": "1.0", "data": "{}" }')) + const { result } = renderHook(() => useGlobalImportJsonParser(JSON.stringify({ version: '1.0', data: {} }))) expect(result.current).toEqual({ addedSafes: undefined, addressBook: undefined, addressBookEntriesCount: 0, addedSafesCount: 0, error: ImportErrors.NO_IMPORT_DATA_FOUND, + settings: undefined, + safeApps: undefined, + session: undefined, }) }) it('should return empty objects for invalid json', () => { - const { result } = renderHook(() => useGlobalImportJsonParser('{ invalid: json, ')) + const { result } = renderHook(() => useGlobalImportJsonParser('{ invalid: json')) expect(result.current).toEqual({ addedSafes: undefined, addressBook: undefined, addressBookEntriesCount: 0, addedSafesCount: 0, error: ImportErrors.INVALID_JSON_FORMAT, + settings: undefined, + safeApps: undefined, + session: undefined, }) }) @@ -42,7 +99,7 @@ describe('useGlobalImportFileParser', () => { const owner2 = '0x954cD69f0E902439f99156e3eeDA080752c08401' const jsonData = JSON.stringify({ - version: '2.0', + version: '17.0', data: { '_immortal|v2_5__SAFES': `{"${goerliSafeAddress}":{"address":"${goerliSafeAddress}","chainId":"5","threshold":2,"ethBalance":"0.3","totalFiatBalance":"435.08","owners":["${owner1}","${owner2}"],"modules":[],"spendingLimits":[],"balances":[{"tokenAddress":"0x0000000000000000000000000000000000000000","fiatBalance":"435.08100","tokenBalance":"0.3"},{"tokenAddress":"0x61fD3b6d656F39395e32f46E2050953376c3f5Ff","fiatBalance":"0.00000","tokenBalance":"22405.086233211233211233"}],"implementation":{"value":"0x3E5c63644E683549055b9Be8653de26E0B4CD36E"},"loaded":true,"nonce":1,"currentVersion":"1.3.0+L2","needsUpdate":false,"featuresEnabled":["CONTRACT_INTERACTION","DOMAIN_LOOKUP","EIP1559","ERC721","SAFE_APPS","SAFE_TX_GAS_OPTIONAL","SPENDING_LIMIT","TX_SIMULATION","WARNING_BANNER"],"loadedViaUrl":false,"guard":"","collectiblesTag":"1667921524","txQueuedTag":"1667921524","txHistoryTag":"1667400927"}}`, '_immortal|v2_MAINNET__SAFES': `{"${mainnetSafeAddress}":{"address":"${mainnetSafeAddress}","chainId":"1","threshold":1,"ethBalance":"0","totalFiatBalance":"0.00","owners":["${owner1}","${owner2}"],"modules":[],"spendingLimits":[],"balances":[{"tokenAddress":"0x0000000000000000000000000000000000000000","fiatBalance":"0.00000","tokenBalance":"0"}],"implementation":{"value":"0xd9Db270c1B5E3Bd161E8c8503c55cEABeE709552","name":"Gnosis Safe: Singleton 1.3.0","logoUri":"https://safe-transaction-assets.safe.global/contracts/logos/0xd9Db270c1B5E3Bd161E8c8503c55cEABeE709552.png"},"loaded":true,"nonce":2,"currentVersion":"1.3.0","needsUpdate":false,"featuresEnabled":["CONTRACT_INTERACTION","DOMAIN_LOOKUP","EIP1559","ERC721","SAFE_APPS","SAFE_TX_GAS_OPTIONAL","SPENDING_LIMIT","TX_SIMULATION"],"loadedViaUrl":false,"guard":"","collectiblesTag":"1667397095","txQueuedTag":"1667397095","txHistoryTag":"1664287235"}}`, @@ -57,10 +114,15 @@ describe('useGlobalImportFileParser', () => { addressBookEntriesCount: 0, addedSafesCount: 0, error: ImportErrors.INVALID_VERSION, + settings: undefined, + safeApps: undefined, + session: undefined, }) }) - it('should parse added safes correctly', () => { + // 1.0 + + it('should parse v1 added safes correctly', () => { const goerliSafeAddress = '0xAecDFD3A19f777F0c03e6bf99AAfB59937d6467b' const mainnetSafeAddress = '0x7cB6E6Cbc845e79d9CA05F6577141DA36ad398f5' @@ -76,7 +138,7 @@ describe('useGlobalImportFileParser', () => { }) const { result } = renderHook(() => useGlobalImportJsonParser(jsonData)) - const { addedSafes, addedSafesCount, addressBook, addressBookEntriesCount } = result.current + const { addedSafes, addedSafesCount, addressBook, addressBookEntriesCount, safeApps, settings } = result.current // No addressbook data expect(addressBookEntriesCount).toEqual(0) @@ -94,9 +156,13 @@ describe('useGlobalImportFileParser', () => { expect(addedSafes['1'][mainnetSafeAddress]).toBeDefined() const mainnetAddedSafe = addedSafes['1'][mainnetSafeAddress] expect(mainnetAddedSafe.threshold).toEqual(1) + + // Only v2 + expect(safeApps).toEqual(undefined) + expect(settings).toEqual(undefined) }) - it('should parse address book entries correctly', () => { + it('should parse v1 address book entries correctly', () => { const goerliAddress1 = '0xAecDFD3A19f777F0c03e6bf99AAfB59937d6467b' const goerliName1 = 'test.eth' const goerliAddress2 = '0x3819b800c67Be64029C1393c8b2e0d0d627dADE2' @@ -118,7 +184,7 @@ describe('useGlobalImportFileParser', () => { const { result } = renderHook(() => useGlobalImportJsonParser(jsonData)) - const { addedSafes, addedSafesCount, addressBook, addressBookEntriesCount } = result.current + const { addedSafes, addedSafesCount, addressBook, addressBookEntriesCount, safeApps, settings } = result.current // no added safes // No addressbook data @@ -134,5 +200,160 @@ describe('useGlobalImportFileParser', () => { expect(addressBook['1'][mainnetAddress1]).toEqual(mainnetName1) expect(addressBook['1'][mainnetAddress2]).toEqual(mainnetName2) + + // Only v2 + expect(safeApps).toEqual(undefined) + expect(settings).toEqual(undefined) + }) + + // 2.0 + + it('should parse v2 added Safes correctly', () => { + const goerliSafeAddress = '0xAecDFD3A19f777F0c03e6bf99AAfB59937d6467b' + const mainnetSafeAddress = '0x7cB6E6Cbc845e79d9CA05F6577141DA36ad398f5' + + const owner1 = '0x3819b800c67Be64029C1393c8b2e0d0d627dADE2' + const owner2 = '0x954cD69f0E902439f99156e3eeDA080752c08401' + + const jsonData = JSON.stringify({ + version: '2.0', + data: { + addedSafes: { + '5': { + [goerliSafeAddress]: { + owners: [{ value: owner1 }, { value: owner2 }], + threshold: 2, + ethBalance: '0', + }, + }, + '1': { + [mainnetSafeAddress]: { + owners: [{ value: owner1 }, { value: owner2 }], + threshold: 1, + ethBalance: '0', + }, + }, + }, + }, + }) + + const { result } = renderHook(() => useGlobalImportJsonParser(jsonData)) + + const { addedSafes, addedSafesCount } = result.current + + expect(addedSafesCount).toEqual(2) + + expect(addedSafes).toBeDefined() + if (!addedSafes) { + fail('No added Safes found') + } + + expect(addedSafes['5'][goerliSafeAddress]).toBeDefined() + + const goerliAddedSafe = addedSafes['5'][goerliSafeAddress] + expect(goerliAddedSafe.threshold).toEqual(2) + + expect(addedSafes['1'][mainnetSafeAddress]).toBeDefined() + const mainnetAddedSafe = addedSafes['1'][mainnetSafeAddress] + expect(mainnetAddedSafe.threshold).toEqual(1) + }) + + it('should parse v2 address book entries correctly', () => { + const goerliAddress1 = '0xAecDFD3A19f777F0c03e6bf99AAfB59937d6467b' + const goerliName1 = 'test.eth' + const goerliAddress2 = '0x3819b800c67Be64029C1393c8b2e0d0d627dADE2' + const goerliName2 = 'some.eth' + const mainnetAddress1 = '0x954cD69f0E902439f99156e3eeDA080752c08401' + const mainnetName1 = 'mobile owner' + const mainnetAddress2 = '0x7cB6E6Cbc845e79d9CA05F6577141DA36ad398f5' + const mainnetName2 = 'S0mE&W3!rd#N4m€' + + const jsonData = JSON.stringify({ + version: '2.0', + data: { + addressBook: { + '5': { + [goerliAddress1]: goerliName1, + [goerliAddress2]: goerliName2, + }, + '1': { + [mainnetAddress1]: mainnetName1, + [mainnetAddress2]: mainnetName2, + }, + }, + }, + }) + + const { result } = renderHook(() => useGlobalImportJsonParser(jsonData)) + + const { addressBook, addressBookEntriesCount } = result.current + + expect(addressBookEntriesCount).toEqual(4) + + if (!addressBook) { + fail('No address book found') + } + + expect(addressBook['5'][goerliAddress1]).toEqual(goerliName1) + expect(addressBook['5'][goerliAddress2]).toEqual(goerliName2) + + expect(addressBook['1'][mainnetAddress1]).toEqual(mainnetName1) + expect(addressBook['1'][mainnetAddress2]).toEqual(mainnetName2) + }) + + it('should parse v2 settings correctly', () => { + const jsonData = JSON.stringify({ + version: '2.0', + data: { + settings: { + currency: 'usd', + shortName: { show: true, copy: true, qr: true }, + theme: { darkMode: false }, + }, + }, + }) + + const { result } = renderHook(() => useGlobalImportJsonParser(jsonData)) + + const { settings } = result.current + + if (!settings) { + fail('No settings found') + } + + expect(settings.currency).toEqual('usd') + + expect(settings.shortName.show).toEqual(true) + expect(settings.shortName.copy).toEqual(true) + expect(settings.shortName.qr).toEqual(true) + + expect(settings.theme.darkMode).toEqual(false) + }) + + it('should parse v2 Safe app settings correctly', () => { + const jsonData = JSON.stringify({ + version: '2.0', + data: { + safeApps: { + '5': { + pinned: [1, 2, 3], + }, + '1': { + pinned: [4, 5, 6], + }, + }, + }, + }) + + const { result } = renderHook(() => useGlobalImportJsonParser(jsonData)) + + const { safeApps } = result.current + + if (!safeApps) { + fail('No Safe app settings found') + } + + expect(safeApps['5'].pinned).toEqual([1, 2, 3]) + expect(safeApps['1'].pinned).toEqual([4, 5, 6]) }) }) diff --git a/src/components/settings/DataManagement/index.tsx b/src/components/settings/DataManagement/index.tsx index 595489c4..800334a6 100644 --- a/src/components/settings/DataManagement/index.tsx +++ b/src/components/settings/DataManagement/index.tsx @@ -1,42 +1,144 @@ -import { useState } from 'react' -import { Paper, Grid, Typography, Button, Link } from '@mui/material' -import Track from '@/components/common/Track' -import { SETTINGS_EVENTS } from '@/services/analytics' -import ImportAllDialog from '../ImportAllDialog' +import { useEffect, useState } from 'react' +import { Paper, Grid, Typography, Button, SvgIcon, Box } from '@mui/material' + +import FileIcon from '@/public/images/settings/data/file.svg' +import ExportIcon from '@/public/images/common/export.svg' +import { getPersistedState, useAppSelector } from '@/store' +import { addressBookSlice, selectAllAddressBooks } from '@/store/addressBookSlice' +import { addedSafesSlice, selectAllAddedSafes } from '@/store/addedSafesSlice' +import { safeAppsSlice, selectSafeApps } from '@/store/safeAppsSlice' +import { selectSettings, settingsSlice } from '@/store/settingsSlice' +// import InfoIcon from '@/public/images/notifications/info.svg' +// import ExternalLink from '@/components/common/ExternalLink' +import { ImportFileUpload } from '@/components/settings/DataManagement/ImportFileUpload' +import { ImportDialog } from '@/components/settings/DataManagement/ImportDialog' +import { SAFE_EXPORT_VERSION } from '@/components/settings/DataManagement/useGlobalImportFileParser' +import { FileListCard } from '@/components/settings/DataManagement/FileListCard' +import { SETTINGS_EVENTS, trackEvent } from '@/services/analytics' + +import css from './styles.module.css' + +const getExportFileName = () => { + const today = new Date().toISOString().slice(0, 10) + return `safe-${today}.json` +} + +export const exportAppData = () => { + // Extract the slices we want to export + const { + [addressBookSlice.name]: addressBook, + [addedSafesSlice.name]: addedSafes, + [settingsSlice.name]: setting, + [safeAppsSlice.name]: safeApps, + } = getPersistedState() + + // Ensure they are under the same name as the slice + const exportData = { + [addressBookSlice.name]: addressBook, + [addedSafesSlice.name]: addedSafes, + [settingsSlice.name]: setting, + [safeAppsSlice.name]: safeApps, + } + + const data = JSON.stringify({ version: SAFE_EXPORT_VERSION.V2, data: exportData }) + + const blob = new Blob([data], { type: 'text/json' }) + const link = document.createElement('a') + + link.download = getExportFileName() + link.href = window.URL.createObjectURL(blob) + link.dataset.downloadurl = ['text/json', link.download, link.href].join(':') + link.dispatchEvent(new MouseEvent('click')) + + trackEvent(SETTINGS_EVENTS.DATA.EXPORT_ALL_BUTTON) +} const DataManagement = () => { - const [modalOpen, setModalOpen] = useState(false) + const [exportFileName, setExportFileName] = useState('') + const [importFileName, setImportFileName] = useState() + const [jsonData, setJsonData] = useState() + + const addedSafes = useAppSelector(selectAllAddedSafes) + const addressBook = useAppSelector(selectAllAddressBooks) + const settings = useAppSelector(selectSettings) + const safeApps = useAppSelector(selectSafeApps) + + useEffect(() => { + // Prevent hydration errors + setExportFileName(getExportFileName()) + }, []) return ( - - - - - Data import - - + <> + + + + + Data export + + - - - You can export your data from the{' '} - - old app - - . - + + Download your local data with your added Safe Accounts, address book and settings. + + `${shape.borderRadius}px` }}> + + + } + title={{exportFileName}} + action={ + + } + addedSafes={addedSafes} + addressBook={addressBook} + settings={settings} + safeApps={safeApps} + /> + {/* + + You can also export your data from the{' '} + old app + */} + + + - The imported data will overwrite all added Safes and all address book entries. + + + + + Data import + + - - - + + + - {modalOpen && setModalOpen(false)} />} + {jsonData && ( + + )} - - + + ) } diff --git a/src/components/settings/DataManagement/styles.module.css b/src/components/settings/DataManagement/styles.module.css new file mode 100644 index 00000000..f5450388 --- /dev/null +++ b/src/components/settings/DataManagement/styles.module.css @@ -0,0 +1,48 @@ +.card { + width: 100%; + border: 1px solid var(--color-border-light); + margin: var(--space-2) 0; +} + +.fileIcon { + display: flex; + align-items: center; + padding: var(--space-1); + border: 1px solid var(--color-text-primary); +} + +.exportIcon { + min-width: unset; + padding: var(--space-1); +} + +.header { + border-bottom: 1px solid var(--color-border-light); +} + +.header :global .MuiCardHeader-avatar { + margin-right: var(--space-2); +} + +.header :global .MuiCardHeader-action { + align-self: center; + margin: 0; +} + +.content { + padding: var(--space-3); +} + +.listIcon { + min-width: unset; + margin-right: var(--space-3); + padding-top: var(--space-1); + align-self: flex-start; +} + +.networkIcon { + width: 10px; + height: 10px; + border-radius: 2px; + margin-right: calc(var(--space-1) / 2); +} diff --git a/src/components/settings/DataManagement/useGlobalImportFileParser.ts b/src/components/settings/DataManagement/useGlobalImportFileParser.ts new file mode 100644 index 00000000..105cfc86 --- /dev/null +++ b/src/components/settings/DataManagement/useGlobalImportFileParser.ts @@ -0,0 +1,133 @@ +import { logError } from '@/services/exceptions' +import ErrorCodes from '@/services/exceptions/ErrorCodes' +import { migrateAddedSafes } from '@/services/ls-migration/addedSafes' +import { migrateAddressBook } from '@/services/ls-migration/addressBook' +import { isChecksummedAddress } from '@/utils/addresses' +import type { AddressBook, AddressBookState } from '@/store/addressBookSlice' +import type { AddedSafesState } from '@/store/addedSafesSlice' +import type { SafeAppsState } from '@/store/safeAppsSlice' +import type { SettingsState } from '@/store/settingsSlice' + +import { useMemo } from 'react' + +export const enum SAFE_EXPORT_VERSION { + V1 = '1.0', + V2 = '2.0', +} + +export enum ImportErrors { + INVALID_VERSION = 'The file is not a Evmos Safe export.', + INVALID_JSON_FORMAT = 'The JSON format is invalid.', + NO_IMPORT_DATA_FOUND = 'This file contains no importable data.', +} + +const countEntries = (data: { [chainId: string]: { [address: string]: unknown } }) => + Object.values(data).reduce((count, entry) => count + Object.keys(entry).length, 0) + +export const _filterValidAbEntries = (ab?: AddressBookState): AddressBookState | undefined => { + if (!ab) { + return undefined + } + + return Object.entries(ab).reduce((acc, [chainId, chainAb]) => { + const sanitizedChainAb = Object.entries(chainAb).reduce((acc, [address, name]) => { + // Legacy imported address books could have undefined name or address entries + if (name?.trim() && address && isChecksummedAddress(address)) { + acc[address] = name + } + return acc + }, {}) + + if (Object.keys(sanitizedChainAb).length > 0) { + acc[chainId] = sanitizedChainAb + } + + return acc + }, {}) +} + +/** + * The global import currently imports: + * 1.0: + * - address book + * - added Safes + * + * 2.0: + * - address book + * - added Safes + * - safeApps + * - settings + * + * @param jsonData + * @returns data to import and some insights about it + */ + +type Data = { + addedSafes?: AddedSafesState + addressBook?: AddressBookState + settings?: SettingsState + safeApps?: SafeAppsState + error?: ImportErrors + addressBookEntriesCount: number + addedSafesCount: number +} + +export const useGlobalImportJsonParser = (jsonData: string | undefined): Data => { + return useMemo(() => { + const data: Data = { + addressBookEntriesCount: 0, + addedSafesCount: 0, + addressBook: undefined, + addedSafes: undefined, + settings: undefined, + safeApps: undefined, + } + + if (!jsonData) { + return data + } + + let parsedFile + + try { + parsedFile = JSON.parse(jsonData) + } catch (err) { + logError(ErrorCodes._704, (err as Error).message) + + data.error = ImportErrors.INVALID_JSON_FORMAT + return data + } + + if (!parsedFile.data || Object.keys(parsedFile.data).length === 0) { + data.error = ImportErrors.NO_IMPORT_DATA_FOUND + return data + } + + switch (parsedFile.version) { + case SAFE_EXPORT_VERSION.V1: { + data.addressBook = migrateAddressBook(parsedFile.data) ?? undefined + data.addedSafes = migrateAddedSafes(parsedFile.data) ?? undefined + + break + } + + case SAFE_EXPORT_VERSION.V2: { + data.addressBook = _filterValidAbEntries(parsedFile.data.addressBook) + data.addedSafes = parsedFile.data.addedSafes + data.settings = parsedFile.data.settings + data.safeApps = parsedFile.data.safeApps + + break + } + + default: { + data.error = ImportErrors.INVALID_VERSION + } + } + + data.addressBookEntriesCount = data.addressBook ? countEntries(data.addressBook) : 0 + data.addedSafesCount = data.addedSafes ? countEntries(data.addedSafes) : 0 + + return data + }, [jsonData]) +} diff --git a/src/components/settings/EnvironmentVariables/EnvHintButton/index.tsx b/src/components/settings/EnvironmentVariables/EnvHintButton/index.tsx index aa501399..ae58bfed 100644 --- a/src/components/settings/EnvironmentVariables/EnvHintButton/index.tsx +++ b/src/components/settings/EnvironmentVariables/EnvHintButton/index.tsx @@ -6,10 +6,12 @@ import { useAppSelector } from '@/store' import { isEnvInitialState } from '@/store/settingsSlice' import css from './styles.module.css' import AlertIcon from '@/public/images/common/alert.svg' +import useChainId from '@/hooks/useChainId' const EnvHintButton = () => { const router = useRouter() - const isInitialState = useAppSelector(isEnvInitialState) + const chainId = useChainId() + const isInitialState = useAppSelector((state) => isEnvInitialState(state, chainId)) if (isInitialState) { return null diff --git a/src/components/settings/EnvironmentVariables/index.tsx b/src/components/settings/EnvironmentVariables/index.tsx index 2435ee0b..d2b45729 100644 --- a/src/components/settings/EnvironmentVariables/index.tsx +++ b/src/components/settings/EnvironmentVariables/index.tsx @@ -3,7 +3,7 @@ import { Paper, Grid, Typography, TextField, Button, Tooltip, IconButton, SvgIco import InputAdornment from '@mui/material/InputAdornment' import RotateLeftIcon from '@mui/icons-material/RotateLeft' import { useAppDispatch, useAppSelector } from '@/store' -import { selectSettings, setEnv } from '@/store/settingsSlice' +import { selectSettings, setRpc, setTenderly } from '@/store/settingsSlice' import { TENDERLY_SIMULATE_ENDPOINT_URL } from '@/config/constants' import useChainId from '@/hooks/useChainId' import { useCurrentChain } from '@/hooks/useChains' @@ -46,15 +46,21 @@ const EnvironmentVariables = () => { const onSubmit = handleSubmit((data) => { trackEvent({ ...SETTINGS_EVENTS.ENV_VARIABLES.SAVE }) + + dispatch( + setRpc({ + chainId, + rpc: data[EnvVariablesField.rpc], + }), + ) + dispatch( - setEnv({ - rpc: data[EnvVariablesField.rpc] ? { [chainId]: data[EnvVariablesField.rpc] } : {}, - tenderly: { - url: data[EnvVariablesField.tenderlyURL], - accessToken: data[EnvVariablesField.tenderlyToken], - }, + setTenderly({ + url: data[EnvVariablesField.tenderlyURL], + accessToken: data[EnvVariablesField.tenderlyToken], }), ) + location.reload() }) diff --git a/src/components/settings/FallbackHandler/__tests__/index.test.tsx b/src/components/settings/FallbackHandler/__tests__/index.test.tsx new file mode 100644 index 00000000..1cb7be45 --- /dev/null +++ b/src/components/settings/FallbackHandler/__tests__/index.test.tsx @@ -0,0 +1,240 @@ +import { act, fireEvent, render, waitFor } from '@/tests/test-utils' + +import * as useSafeInfoHook from '@/hooks/useSafeInfo' +import * as useTxBuilderHook from '@/hooks/safe-apps/useTxBuilderApp' +import { FallbackHandler } from '..' + +const GOERLI_FALLBACK_HANDLER = '0xf48f2B2d2a534e402487b3ee7C18c33Aec0Fe5e4' + +describe('FallbackHandler', () => { + beforeEach(() => { + jest.clearAllMocks() + + jest.spyOn(useTxBuilderHook, 'useTxBuilderApp').mockImplementation(() => ({ + link: { href: 'https://mock.link/tx-builder' }, + })) + }) + + it('should render the Fallback Handler when one is set', async () => { + jest.spyOn(useSafeInfoHook, 'default').mockImplementation( + () => + ({ + safe: { + version: '1.3.0', + chainId: '5', + fallbackHandler: { + value: GOERLI_FALLBACK_HANDLER, + name: 'FallbackHandlerName', + }, + }, + } as unknown as ReturnType), + ) + + const fbHandler = render() + + await waitFor(() => { + expect( + fbHandler.queryByText( + 'The fallback handler adds fallback logic for funtionality that may not be present in the Safe contract. Learn more about the fallback handler', + ), + ).toBeDefined() + + expect(fbHandler.getByText(GOERLI_FALLBACK_HANDLER)).toBeDefined() + + expect(fbHandler.getByText('FallbackHandlerName')).toBeDefined() + }) + }) + + it('should use the official deployment name if the address is official but no known name is present', async () => { + jest.spyOn(useSafeInfoHook, 'default').mockImplementation( + () => + ({ + safe: { + version: '1.3.0', + chainId: '5', + fallbackHandler: { + value: GOERLI_FALLBACK_HANDLER, + }, + }, + } as unknown as ReturnType), + ) + + const fbHandler = render() + + await waitFor(() => { + expect(fbHandler.getByText('CompatibilityFallbackHandler')).toBeDefined() + }) + }) + + describe('No Fallback Handler', () => { + it('should render the Fallback Handler and warning tooltip when no Fallback Handler is set', async () => { + jest.spyOn(useSafeInfoHook, 'default').mockImplementation( + () => + ({ + safe: { + version: '1.3.0', + chainId: '5', + }, + } as unknown as ReturnType), + ) + + const fbHandler = render() + + await waitFor(() => { + expect(fbHandler.getByText('No fallback handler set')).toBeDefined() + }) + + const icon = fbHandler.getByTestId('fallback-handler-warning') + + await act(() => { + fireEvent( + icon, + new MouseEvent('mouseover', { + bubbles: true, + }), + ) + }) + + await waitFor(() => { + expect( + fbHandler.queryByText( + new RegExp('The Evmos Safe may not work correctly as no fallback handler is currently set.'), + ), + ).toBeInTheDocument() + expect(fbHandler.queryByText('Transaction Builder')).toBeInTheDocument() + }) + }) + + it('should conditionally append the Transaction Builder link', async () => { + jest.spyOn(useTxBuilderHook, 'useTxBuilderApp').mockImplementation(() => undefined) + + jest.spyOn(useSafeInfoHook, 'default').mockImplementation( + () => + ({ + safe: { + version: '1.3.0', + chainId: '5', + }, + } as unknown as ReturnType), + ) + + const fbHandler = render() + + const icon = fbHandler.getByTestId('fallback-handler-warning') + + await act(() => { + fireEvent( + icon, + new MouseEvent('mouseover', { + bubbles: true, + }), + ) + }) + + await waitFor(() => { + expect( + fbHandler.queryByText( + new RegExp('The Evmos Safe may not work correctly as no fallback handler is currently set.'), + ), + ).toBeInTheDocument() + expect(fbHandler.queryByText('Transaction Builder')).not.toBeInTheDocument() + }) + }) + }) + + describe('Unofficial Fallback Handler', () => { + it('should render placeholder and warning tooltip when an unofficial Fallback Handler is set', async () => { + jest.spyOn(useSafeInfoHook, 'default').mockImplementation( + () => + ({ + safe: { + version: '1.3.0', + chainId: '5', + fallbackHandler: { + value: '0x123', + }, + }, + } as unknown as ReturnType), + ) + + const fbHandler = render() + + await waitFor(() => { + expect( + fbHandler.queryByText( + 'The fallback handler adds fallback logic for funtionality that may not be present in the Safe Account contract. Learn more about the fallback handler', + ), + ).toBeDefined() + + expect(fbHandler.getByText('0x123')).toBeDefined() + }) + + const icon = fbHandler.getByTestId('fallback-handler-warning') + + await act(() => { + fireEvent( + icon, + new MouseEvent('mouseover', { + bubbles: true, + }), + ) + }) + + await waitFor(() => { + expect(fbHandler.queryByText(new RegExp('An unofficial fallback handler is currently set.'))) + expect(fbHandler.queryByText('Transaction Builder')).toBeInTheDocument() + }) + }) + + it('should conditionally append the Transaction Builder link', async () => { + jest.spyOn(useTxBuilderHook, 'useTxBuilderApp').mockImplementation(() => undefined) + + jest.spyOn(useSafeInfoHook, 'default').mockImplementation( + () => + ({ + safe: { + version: '1.3.0', + chainId: '5', + fallbackHandler: { + value: '0x123', + }, + }, + } as unknown as ReturnType), + ) + + const fbHandler = render() + + const icon = fbHandler.getByTestId('fallback-handler-warning') + + await act(() => { + fireEvent( + icon, + new MouseEvent('mouseover', { + bubbles: true, + }), + ) + }) + + await waitFor(() => { + expect(fbHandler.queryByText(new RegExp('An unofficial fallback handler is currently set.'))) + expect(fbHandler.queryByText('Transaction Builder')).not.toBeInTheDocument() + }) + }) + }) + + it('should render nothing if the Safe Account version does not support Fallback Handlers', () => { + jest.spyOn(useSafeInfoHook, 'default').mockImplementation( + () => + ({ + safe: { + version: '1.0.0', + chainId: '5', + }, + } as unknown as ReturnType), + ) + + const fbHandler = render() + + expect(fbHandler.container).toBeEmptyDOMElement() + }) +}) diff --git a/src/components/settings/FallbackHandler/index.tsx b/src/components/settings/FallbackHandler/index.tsx new file mode 100644 index 00000000..aba9f2df --- /dev/null +++ b/src/components/settings/FallbackHandler/index.tsx @@ -0,0 +1,119 @@ +import NextLink from 'next/link' +import { Typography, Box, SvgIcon, Tooltip, Grid, Paper, Link } from '@mui/material' +import semverSatisfies from 'semver/functions/satisfies' +import { useMemo } from 'react' +import type { ReactElement } from 'react' + +import EthHashInfo from '@/components/common/EthHashInfo' +import AlertIcon from '@/public/images/common/alert.svg' +import useSafeInfo from '@/hooks/useSafeInfo' +import { getFallbackHandlerDeployment } from '@safe-global/safe-deployments' +import { HelpCenterArticle, LATEST_SAFE_VERSION } from '@/config/constants' +import ExternalLink from '@/components/common/ExternalLink' +import { useTxBuilderApp } from '@/hooks/safe-apps/useTxBuilderApp' + +import css from '../SafeModules/styles.module.css' + +const FALLBACK_HANDLER_VERSION = '>=1.1.1' + +export const FallbackHandler = (): ReactElement | null => { + const { safe } = useSafeInfo() + const txBuilder = useTxBuilderApp() + + const supportsFallbackHandler = !!safe.version && semverSatisfies(safe.version, FALLBACK_HANDLER_VERSION) + + const fallbackHandlerDeployment = useMemo(() => { + return getFallbackHandlerDeployment({ + version: safe.version || LATEST_SAFE_VERSION, + network: safe.chainId, + }) + }, [safe.version, safe.chainId]) + + if (!supportsFallbackHandler) { + return null + } + + const isOfficial = !!safe.fallbackHandler && safe.fallbackHandler.value === fallbackHandlerDeployment?.defaultAddress + + const tooltip = !safe.fallbackHandler ? ( + <> + The {'Evmos Safe'} may not work correctly as no fallback handler is currently set. + {txBuilder && ( + <> + {' '} + It can be set via the{' '} + + Transaction Builder + + . + + )} + + ) : !isOfficial ? ( + <> + An unofficial fallback handler is currently set. + {txBuilder && ( + <> + {' '} + It can be altered via the{' '} + + Transaction Builder + + . + + )} + + ) : undefined + + return ( + + + + + Fallback handler + {tooltip && ( + + + + + + )} + + + + + + + The fallback handler adds fallback logic for funtionality that may not be present in the Safe Account + contract. Learn more about the fallback handler{' '} + here + + {safe.fallbackHandler ? ( + + + + ) : ( + palette.primary.light}> + No fallback handler set + + )} + + + + + ) +} diff --git a/src/components/settings/ImportAllDialog/documentation.md b/src/components/settings/ImportAllDialog/documentation.md deleted file mode 100644 index 6d8b8446..00000000 --- a/src/components/settings/ImportAllDialog/documentation.md +++ /dev/null @@ -1,91 +0,0 @@ -## Data Import / Export - -Currently we only support the importing of data from our old web interface (safe-react) to the new one (web-core). - -### How does the export work? - -In the old interface navigate to `Settings -> Details -> Download your data`. This button will download a `.json` file which contains the **entire localStorage**. -The export files have this format: - -```json -{ - "version": "1.0", - "data": { - - } -} -``` - -### How does the import work? - -In the new interface navigate to `/import` or `Settings -> Data` and open the _Import all data_ modal. - -This will only import specific data: - -- The added Safes -- The (valid\*) address book entries - -* Only named, checksummed address book entries will be added. - -#### Address book - -All address book entries are stored under the key `SAFE__addressBook`. -This entry contains a stringified address book with the following format: - -```ts -{ - address: string - name: string - chainId: string -} -;[] -``` - -Example: - -```json -{ - "version": "1.0", - "data": { - "SAFE__addressBook": "[{\"address\":\"0xB5E64e857bb7b5350196C5BAc8d639ceC1072745\",\"name\":\"Testname\",\"chainId\":\"5\"},{\"address\":\"0x08f6466dD7891ac9A60C769c7521b0CF2F60c153\",\"name\":\"authentic-goerli-safe\",\"chainId\":\"5\"}]" - } -} -``` - -#### Added safes - -Added safes are stored under one entry per chain. -Each entry has a key in following format: `_immortal|v2___SAFES` -The chain prefix is either the chain ID or prefix, as follows: - -``` - '1': 'MAINNET', - '56': 'BSC', - '100': 'XDAI', - '137': 'POLYGON', - '246': 'ENERGY_WEB_CHAIN', - '42161': 'ARBITRUM', - '73799': 'VOLTA', -``` - -Examples: - -- `_immortal|v2_MAINNET__SAFES` for mainnet -- `_immortal|v2_5__SAFES` for goerli (chainId 5) - -Inside each of these keys the full Safe information (including balances) is stored in stringified format. -Example: - -```json -{ - "version": "1.0", - "data": { - "_immortal|v2_5__SAFES": "{\"0xAecDFD3A19f777F0c03e6bf99AAfB59937d6467b\":{\"address\":\"0xAecDFD3A19f777F0c03e6bf99AAfB59937d6467b\",\"chainId\":\"5\",\"threshold\":2,\"ethBalance\":\"0.3\",\"totalFiatBalance\":\"435.08\",\"owners\":[\"0x3819b800c67Be64029C1393c8b2e0d0d627dADE2\",\"0x954cD69f0E902439f99156e3eeDA080752c08401\",\"0xB5E64e857bb7b5350196C5BAc8d639ceC1072745\"],\"modules\":[],\"spendingLimits\":[],\"balances\":[{\"tokenAddress\":\"0x0000000000000000000000000000000000000000\",\"fiatBalance\":\"435.08100\",\"tokenBalance\":\"0.3\"},{\"tokenAddress\":\"0x61fD3b6d656F39395e32f46E2050953376c3f5Ff\",\"fiatBalance\":\"0.00000\",\"tokenBalance\":\"22405.086233211233211233\"}],\"implementation\":{\"value\":\"0x3E5c63644E683549055b9Be8653de26E0B4CD36E\"},\"loaded\":true,\"nonce\":1,\"currentVersion\":\"1.3.0+L2\",\"needsUpdate\":false,\"featuresEnabled\":[\"CONTRACT_INTERACTION\",\"DOMAIN_LOOKUP\",\"EIP1559\",\"ERC721\",\"SAFE_APPS\",\"SAFE_TX_GAS_OPTIONAL\",\"SPENDING_LIMIT\",\"TX_SIMULATION\",\"WARNING_BANNER\"],\"loadedViaUrl\":false,\"guard\":\"\",\"collectiblesTag\":\"1667921524\",\"txQueuedTag\":\"1667921524\",\"txHistoryTag\":\"1667400927\"}}" - } -} -``` - -### Noteworthy - -- Only address book entries with names and checksummed addresses will be imported. -- Rinkeby data will be ignored as it's not supported anymore. diff --git a/src/components/settings/ImportAllDialog/index.tsx b/src/components/settings/ImportAllDialog/index.tsx deleted file mode 100644 index a8f20315..00000000 --- a/src/components/settings/ImportAllDialog/index.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import DialogContent from '@mui/material/DialogContent' -import DialogActions from '@mui/material/DialogActions' -import Button from '@mui/material/Button' -import Typography from '@mui/material/Typography' -import { type ReactElement, useState } from 'react' - -import ModalDialog from '@/components/common/ModalDialog' -import { useAppDispatch } from '@/store' - -import { useDropzone } from 'react-dropzone' -import { addedSafesSlice } from '@/store/addedSafesSlice' -import { addressBookSlice } from '@/store/addressBookSlice' - -import css from './styles.module.css' -import type { MouseEventHandler } from 'react' -import { useGlobalImportJsonParser } from './useGlobalImportFileParser' -import { showNotification } from '@/store/notificationsSlice' -import { Alert, AlertTitle } from '@mui/material' -import { SETTINGS_EVENTS, trackEvent } from '@/services/analytics' -import FileUpload, { FileTypes, type FileInfo } from '@/components/common/FileUpload' - -const AcceptedMimeTypes = { - 'application/json': ['.json'], -} - -const ImportAllDialog = ({ handleClose }: { handleClose: () => void }): ReactElement => { - const [jsonData, setJsonData] = useState() - const [fileName, setFileName] = useState() - - // Parse the jsonData whenever it changes - const { addedSafes, addedSafesCount, addressBook, addressBookEntriesCount, error } = - useGlobalImportJsonParser(jsonData) - - const dispatch = useAppDispatch() - - const onDrop = (acceptedFiles: File[]) => { - if (acceptedFiles.length === 0) { - return - } - const file = acceptedFiles[0] - const reader = new FileReader() - reader.onload = (event) => { - if (!event.target) { - return - } - if (typeof event.target.result !== 'string') { - return - } - setJsonData(event.target.result) - setFileName(file.name) - } - reader.readAsText(file) - } - - const onRemove: MouseEventHandler = (event) => { - setJsonData(undefined) - setFileName(undefined) - event.preventDefault() - event.stopPropagation() - } - - const { getRootProps, getInputProps, isDragActive, isDragReject } = useDropzone({ - maxFiles: 1, - onDrop, - accept: AcceptedMimeTypes, - }) - - const handleImport = () => { - if (!addressBook && !addedSafes) { - return - } - - if (addressBook) { - dispatch(addressBookSlice.actions.setAddressBook(addressBook)) - - trackEvent({ - ...SETTINGS_EVENTS.DATA.IMPORT_ADDRESS_BOOK, - label: addressBookEntriesCount, - }) - } - - if (addedSafes) { - dispatch(addedSafesSlice.actions.setAddedSafes(addedSafes)) - - trackEvent({ - ...SETTINGS_EVENTS.DATA.IMPORT_ADDED_SAFES, - label: addedSafesCount, - }) - } - - dispatch( - showNotification({ - variant: 'success', - groupKey: 'global-import-success', - message: 'Successfully imported data', - detailedMessage: [ - ...(addedSafesCount > 0 ? [`${addedSafesCount} Safes were added.`] : []), - ...(addressBookEntriesCount > 0 - ? [`${addressBookEntriesCount} addresses were added to your address book.`] - : []), - ].join('\n'), - }), - ) - - handleClose() - } - - const fileInfo: FileInfo | undefined = fileName - ? { - name: fileName, - error, - summary: [ - ...(addedSafesCount > 0 && addedSafes - ? [ - - Found {addedSafesCount} Added Safes entries on{' '} - {Object.keys(addedSafes).length} chain(s) - , - ] - : []), - ...(addressBookEntriesCount > 0 && addressBook - ? [ - - Found {addressBookEntriesCount} Address book entries on{' '} - {Object.keys(addressBook).length} chain(s) - , - ] - : []), - ], - } - : undefined - - return ( - - - - -

- - Only JSON files exported from a Safe can be imported. - - Overwrite your current data? - This action will overwrite your currently added Safes and address book entries with those from the imported - file. - - - - - - - - ) -} - -export default ImportAllDialog diff --git a/src/components/settings/ImportAllDialog/styles.module.css b/src/components/settings/ImportAllDialog/styles.module.css deleted file mode 100644 index 4891aa8b..00000000 --- a/src/components/settings/ImportAllDialog/styles.module.css +++ /dev/null @@ -1,5 +0,0 @@ -.horizontalDivider { - display: flex; - margin: 24px -24px; - border-top: 2px solid rgba(0, 0, 0, 0.12); -} diff --git a/src/components/settings/ImportAllDialog/useGlobalImportFileParser.ts b/src/components/settings/ImportAllDialog/useGlobalImportFileParser.ts deleted file mode 100644 index 40e07cf3..00000000 --- a/src/components/settings/ImportAllDialog/useGlobalImportFileParser.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { logError } from '@/services/exceptions' -import ErrorCodes from '@/services/exceptions/ErrorCodes' -import { migrateAddedSafes } from '@/services/ls-migration/addedSafes' -import { migrateAddressBook } from '@/services/ls-migration/addressBook' -import { useMemo } from 'react' - -const V1 = '1.0' - -export enum ImportErrors { - INVALID_VERSION = 'The file is not a Safe export.', - INVALID_JSON_FORMAT = 'The JSON format is invalid.', - NO_IMPORT_DATA_FOUND = 'This file contains no importable data.', -} - -const countEntries = (data: { [chainId: string]: { [address: string]: unknown } }) => - Object.values(data).reduce((count, entry) => count + Object.keys(entry).length, 0) - -/** - * The global import currently imports: - * - all addressbook entries - * - all addedSafes - * - * @param jsonData - * @returns data to import and some insights about it - */ -export const useGlobalImportJsonParser = (jsonData: string | undefined) => { - const [migrationAddedSafes, migrationAddressbook, addressBookEntriesCount, addedSafesCount, error] = useMemo(() => { - if (!jsonData) { - return [undefined, undefined, 0, 0, undefined] - } - try { - const parsedFile = JSON.parse(jsonData) - - // We only understand v1 data so far - if (!parsedFile.data || parsedFile.version !== V1) { - return [undefined, undefined, 0, 0, ImportErrors.INVALID_VERSION] - } - - const abData = migrateAddressBook(parsedFile.data) - const addedSafesData = migrateAddedSafes(parsedFile.data) - - const abCount = abData ? countEntries(abData) : 0 - const addedSafesCount = addedSafesData ? countEntries(addedSafesData) : 0 - - return [ - addedSafesData, - abData, - abCount, - addedSafesCount, - !abData && !addedSafesData ? ImportErrors.NO_IMPORT_DATA_FOUND : undefined, - ] - } catch (err) { - logError(ErrorCodes._704, (err as Error).message) - return [undefined, undefined, 0, 0, ImportErrors.INVALID_JSON_FORMAT] - } - }, [jsonData]) - - return { - addedSafes: migrationAddedSafes, - addressBook: migrationAddressbook, - addressBookEntriesCount, - addedSafesCount, - error, - } -} diff --git a/src/components/settings/RequiredConfirmations/index.tsx b/src/components/settings/RequiredConfirmations/index.tsx index a7da15db..74837884 100644 --- a/src/components/settings/RequiredConfirmations/index.tsx +++ b/src/components/settings/RequiredConfirmations/index.tsx @@ -1,15 +1,7 @@ import { ChangeThresholdDialog } from '@/components/settings/owner/ChangeThresholdDialog' import { Box, Grid, Typography } from '@mui/material' -export const RequiredConfirmation = ({ - threshold, - owners, - isGranted, -}: { - threshold: number - owners: number - isGranted: boolean -}) => { +export const RequiredConfirmation = ({ threshold, owners }: { threshold: number; owners: number }) => { return ( @@ -24,7 +16,8 @@ export const RequiredConfirmation = ({ {threshold} out of {owners} owners. - {isGranted && owners > 1 && } + + {owners > 1 && } diff --git a/src/components/settings/SafeAppsSigningMethod/index.test.tsx b/src/components/settings/SafeAppsSigningMethod/index.test.tsx new file mode 100644 index 00000000..b4bc1547 --- /dev/null +++ b/src/components/settings/SafeAppsSigningMethod/index.test.tsx @@ -0,0 +1,27 @@ +import { act, fireEvent, render } from '@/tests/test-utils' +import { SafeAppsSigningMethod } from '.' + +describe('SafeAppsSigningMethod', () => { + it('Toggle on-chain signing', async () => { + const result = render(, { + initialReduxState: { + settings: { + signing: { + useOnChainSigning: false, + }, + } as any, + }, + }) + + const checkbox = result.getByRole('checkbox') + expect(checkbox).not.toBeChecked() + + act(() => fireEvent.click(checkbox)) + + expect(checkbox).toBeChecked() + + act(() => fireEvent.click(checkbox)) + + expect(checkbox).not.toBeChecked() + }) +}) diff --git a/src/components/settings/SafeAppsSigningMethod/index.tsx b/src/components/settings/SafeAppsSigningMethod/index.tsx new file mode 100644 index 00000000..88f9e104 --- /dev/null +++ b/src/components/settings/SafeAppsSigningMethod/index.tsx @@ -0,0 +1,51 @@ +import ExternalLink from '@/components/common/ExternalLink' +import { SETTINGS_EVENTS, trackEvent } from '@/services/analytics' +import { useAppDispatch, useAppSelector } from '@/store' +import { selectOnChainSigning, setOnChainSigning } from '@/store/settingsSlice' +import { FormControlLabel, Checkbox, Paper, Typography, FormGroup, Grid } from '@mui/material' + +export const SafeAppsSigningMethod = () => { + const onChainSigning = useAppSelector(selectOnChainSigning) + + const dispatch = useAppDispatch() + + const onChange = () => { + trackEvent(SETTINGS_EVENTS.SAFE_APPS.CHANGE_SIGNING_METHOD) + dispatch(setOnChainSigning(!onChainSigning)) + } + + return ( + + + + + Signing method + + + + + + This setting determines how the {'Evmos Safe'} will sign message requests from Safe Apps. Gasless, off-chain + signing is used by default. Learn more about message signing{' '} + + here + + . + + + ({ + flex: 1, + '.MuiIconButton-root:not(.Mui-checked)': { + color: palette.text.disabled, + }, + })} + control={} + label="Always use on-chain signatures" + /> + + + + + ) +} diff --git a/src/components/settings/SafeModules/RemoveModule/index.tsx b/src/components/settings/SafeModules/RemoveModule/index.tsx index 0fb54388..6423c420 100644 --- a/src/components/settings/SafeModules/RemoveModule/index.tsx +++ b/src/components/settings/SafeModules/RemoveModule/index.tsx @@ -4,6 +4,7 @@ import { IconButton, SvgIcon } from '@mui/material' import TxModal from '@/components/tx/TxModal' import DeleteIcon from '@/public/images/common/delete.svg' import { ReviewRemoveModule } from '@/components/settings/SafeModules/RemoveModule/steps/ReviewRemoveModule' +import CheckWallet from '@/components/common/CheckWallet' export type RemoveModuleData = { address: string @@ -25,9 +26,14 @@ export const RemoveModule = ({ address }: { address: string }) => { return ( <> - setOpen(true)} color="error" size="small"> - - + + {(isOk) => ( + setOpen(true)} color="error" size="small" disabled={!isOk}> + + + )} + + {open && setOpen(false)} steps={RemoveModuleSteps} initialData={[initialData]} />} ) diff --git a/src/components/settings/SafeModules/RemoveModule/steps/ReviewRemoveModule.tsx b/src/components/settings/SafeModules/RemoveModule/steps/ReviewRemoveModule.tsx index ab7463a1..cef844ad 100644 --- a/src/components/settings/SafeModules/RemoveModule/steps/ReviewRemoveModule.tsx +++ b/src/components/settings/SafeModules/RemoveModule/steps/ReviewRemoveModule.tsx @@ -1,6 +1,5 @@ import useAsync from '@/hooks/useAsync' import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' -import useTxSender from '@/hooks/useTxSender' import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' import { Typography } from '@mui/material' import SendToBlock from '@/components/tx/SendToBlock' @@ -8,12 +7,12 @@ import type { RemoveModuleData } from '@/components/settings/SafeModules/RemoveM import { useEffect } from 'react' import { Errors, logError } from '@/services/exceptions' import { trackEvent, SETTINGS_EVENTS } from '@/services/analytics' +import { createRemoveModuleTx } from '@/services/tx/tx-sender' export const ReviewRemoveModule = ({ data, onSubmit }: { data: RemoveModuleData; onSubmit: () => void }) => { - const { createRemoveModuleTx } = useTxSender() const [safeTx, safeTxError] = useAsync(() => { return createRemoveModuleTx(data.address) - }, [data.address, createRemoveModuleTx]) + }, [data.address]) useEffect(() => { if (safeTxError) { @@ -31,8 +30,8 @@ export const ReviewRemoveModule = ({ data, onSubmit }: { data: RemoveModuleData; - After removing this module, any feature or app that uses this module might no longer work. If this Safe requires - more then one signature, the module removal will have to be confirmed by other owners as well. + After removing this module, any feature or app that uses this module might no longer work. If this Safe Account + requires more then one signature, the module removal will have to be confirmed by other owners as well. ) diff --git a/src/components/settings/SafeModules/index.tsx b/src/components/settings/SafeModules/index.tsx index 5361e1f2..26695c9e 100644 --- a/src/components/settings/SafeModules/index.tsx +++ b/src/components/settings/SafeModules/index.tsx @@ -4,7 +4,6 @@ import { Paper, Grid, Typography, Box } from '@mui/material' import css from './styles.module.css' import { RemoveModule } from '@/components/settings/SafeModules/RemoveModule' -import useIsGranted from '@/hooks/useIsGranted' import ExternalLink from '@/components/common/ExternalLink' const NoModules = () => { @@ -16,8 +15,6 @@ const NoModules = () => { } const ModuleDisplay = ({ moduleAddress, chainId, name }: { moduleAddress: string; chainId: string; name?: string }) => { - const isGranted = useIsGranted() - return ( - {isGranted && } + ) } @@ -42,15 +39,15 @@ const SafeModules = () => { - Safe modules + Safe Account modules - Modules allow you to customize the access-control logic of your Safe. Modules are potentially risky, so - make sure to only use modules from trusted sources. Learn more about modules{' '} + Modules allow you to customize the access-control logic of your Safe Account. Modules are potentially + risky, so make sure to only use modules from trusted sources. Learn more about modules{' '} here {safeModules.length === 0 ? ( diff --git a/src/components/settings/SettingsHeader/index.tsx b/src/components/settings/SettingsHeader/index.tsx index 7fefb885..9efcd616 100644 --- a/src/components/settings/SettingsHeader/index.tsx +++ b/src/components/settings/SettingsHeader/index.tsx @@ -2,10 +2,23 @@ import type { ReactElement } from 'react' import NavTabs from '@/components/common/NavTabs' import PageHeader from '@/components/common/PageHeader' -import { settingsNavItems } from '@/components/sidebar/SidebarNavigation/config' +import { generalSettingsNavItems, settingsNavItems } from '@/components/sidebar/SidebarNavigation/config' +import css from '@/components/common/PageHeader/styles.module.css' +import useSafeAddress from '@/hooks/useSafeAddress' const SettingsHeader = (): ReactElement => { - return } /> + const safeAddress = useSafeAddress() + + return ( + + +
+ } + /> + ) } export default SettingsHeader diff --git a/src/components/settings/SpendingLimits/NewSpendingLimit/index.tsx b/src/components/settings/SpendingLimits/NewSpendingLimit/index.tsx index 993ba495..fbaf98d1 100644 --- a/src/components/settings/SpendingLimits/NewSpendingLimit/index.tsx +++ b/src/components/settings/SpendingLimits/NewSpendingLimit/index.tsx @@ -6,6 +6,7 @@ import { SpendingLimitForm } from '@/components/settings/SpendingLimits/NewSpend import { ReviewSpendingLimit } from '@/components/settings/SpendingLimits/NewSpendingLimit/steps/ReviewSpendingLimit' import Track from '@/components/common/Track' import { SETTINGS_EVENTS } from '@/services/analytics/events/settings' +import CheckWallet from '@/components/common/CheckWallet' const NewSpendingLimitSteps: TxStepperProps['steps'] = [ { @@ -30,11 +31,16 @@ export const NewSpendingLimit = () => { return ( <> - - - + + {(isOk) => ( + + + + )} + + {open && setOpen(false)} steps={NewSpendingLimitSteps} />} ) diff --git a/src/components/settings/SpendingLimits/NewSpendingLimit/steps/ReviewSpendingLimit.tsx b/src/components/settings/SpendingLimits/NewSpendingLimit/steps/ReviewSpendingLimit.tsx index 095d657a..61f8be07 100644 --- a/src/components/settings/SpendingLimits/NewSpendingLimit/steps/ReviewSpendingLimit.tsx +++ b/src/components/settings/SpendingLimits/NewSpendingLimit/steps/ReviewSpendingLimit.tsx @@ -16,8 +16,8 @@ import { relativeTime } from '@/utils/date' import { trackEvent, SETTINGS_EVENTS } from '@/services/analytics' import { TokenTransferReview } from '@/components/tx/modals/TokenTransferModal/ReviewTokenTx' import SpendingLimitLabel from '@/components/common/SpendingLimitLabel' -import useTxSender from '@/hooks/useTxSender' import type { NewSpendingLimitData } from '@/services/tx/tx-sender' +import { createNewSpendingLimitTx } from '@/services/tx/tx-sender' type Props = { data: NewSpendingLimitData @@ -29,7 +29,6 @@ export const ReviewSpendingLimit = ({ data, onSubmit }: Props) => { const spendingLimits = useSelector(selectSpendingLimits) const chainId = useChainId() const { balances } = useBalances() - const { createNewSpendingLimitTx } = useTxSender() useEffect(() => { const existingSpendingLimit = spendingLimits.find( @@ -51,7 +50,7 @@ export const ReviewSpendingLimit = ({ data, onSubmit }: Props) => { const [safeTx, safeTxError] = useAsync(() => { return createNewSpendingLimitTx(data, spendingLimits, chainId, decimals, existingSpendingLimit) - }, [data, spendingLimits, chainId, decimals, existingSpendingLimit, createNewSpendingLimitTx]) + }, [data, spendingLimits, chainId, decimals, existingSpendingLimit]) const onFormSubmit = () => { trackEvent({ diff --git a/src/components/settings/SpendingLimits/NoSpendingLimits.tsx b/src/components/settings/SpendingLimits/NoSpendingLimits.tsx index 05cf9392..53700831 100644 --- a/src/components/settings/SpendingLimits/NoSpendingLimits.tsx +++ b/src/components/settings/SpendingLimits/NoSpendingLimits.tsx @@ -16,7 +16,7 @@ export const NoSpendingLimits = () => {
Choose an account that will benefit from this allowance. The beneficiary does not have to be an owner of this - Safe + Safe Account @@ -27,7 +27,7 @@ export const NoSpendingLimits = () => { Select asset and amount - You can set allowances for any asset stored in your Safe + You can set allowances for any asset stored in your Safe Account diff --git a/src/components/settings/SpendingLimits/RemoveSpendingLimit/index.tsx b/src/components/settings/SpendingLimits/RemoveSpendingLimit/index.tsx index 7645ece4..f65ef2fd 100644 --- a/src/components/settings/SpendingLimits/RemoveSpendingLimit/index.tsx +++ b/src/components/settings/SpendingLimits/RemoveSpendingLimit/index.tsx @@ -1,8 +1,6 @@ import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' import { getSpendingLimitInterface, getSpendingLimitModuleAddress } from '@/services/contracts/spendingLimitContracts' import useChainId from '@/hooks/useChainId' -import { useWeb3 } from '@/hooks/wallets/web3' -import useTxSender from '@/hooks/useTxSender' import useAsync from '@/hooks/useAsync' import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' import EthHashInfo from '@/components/common/EthHashInfo' @@ -14,17 +12,16 @@ import useBalances from '@/hooks/useBalances' import { TokenTransferReview } from '@/components/tx/modals/TokenTransferModal/ReviewTokenTx' import { safeFormatUnits } from '@/utils/formatters' import SpendingLimitLabel from '@/components/common/SpendingLimitLabel' +import { createTx } from '@/services/tx/tx-sender' export const RemoveSpendingLimit = ({ data, onSubmit }: { data: SpendingLimitState; onSubmit: () => void }) => { - const { createTx } = useTxSender() const chainId = useChainId() - const provider = useWeb3() const { balances } = useBalances() const token = balances.items.find((item) => item.tokenInfo.address === data.token.address) const [safeTx, safeTxError] = useAsync(() => { const spendingLimitAddress = getSpendingLimitModuleAddress(chainId) - if (!provider || !spendingLimitAddress) return + if (!spendingLimitAddress) return const spendingLimitInterface = getSpendingLimitInterface() const txData = spendingLimitInterface.encodeFunctionData('deleteAllowance', [data.beneficiary, data.token.address]) @@ -36,7 +33,7 @@ export const RemoveSpendingLimit = ({ data, onSubmit }: { data: SpendingLimitSta } return createTx(txParams) - }, [provider, chainId, data.beneficiary, data.token, createTx]) + }, [chainId, data.beneficiary, data.token]) const onFormSubmit = () => { trackEvent(SETTINGS_EVENTS.SPENDING_LIMIT.LIMIT_REMOVED) diff --git a/src/components/settings/SpendingLimits/SpendingLimitsTable.tsx b/src/components/settings/SpendingLimits/SpendingLimitsTable.tsx index ba47144d..854c6d75 100644 --- a/src/components/settings/SpendingLimits/SpendingLimitsTable.tsx +++ b/src/components/settings/SpendingLimits/SpendingLimitsTable.tsx @@ -10,11 +10,11 @@ import { BigNumber } from '@ethersproject/bignumber' import type { TxStepperProps } from '@/components/tx/TxStepper/useTxStepper' import TxModal from '@/components/tx/TxModal' import { RemoveSpendingLimit } from '@/components/settings/SpendingLimits/RemoveSpendingLimit' -import useIsGranted from '@/hooks/useIsGranted' import Track from '@/components/common/Track' import { SETTINGS_EVENTS } from '@/services/analytics/events/settings' import TokenIcon from '@/components/common/TokenIcon' import SpendingLimitLabel from '@/components/common/SpendingLimitLabel' +import CheckWallet from '@/components/common/CheckWallet' const RemoveSpendingLimitSteps: TxStepperProps['steps'] = [ { @@ -74,9 +74,6 @@ export const SpendingLimitsTable = ({ }) => { const [open, setOpen] = useState(false) const [initialData, setInitialData] = useState() - const isGranted = useIsGranted() - - const shouldHideactions = !isGranted const onRemove = (spendingLimit: SpendingLimitState) => { setOpen(true) @@ -88,9 +85,9 @@ export const SpendingLimitsTable = ({ { id: 'beneficiary', label: 'Beneficiary' }, { id: 'spent', label: 'Spent' }, { id: 'resetTime', label: 'Reset time' }, - { id: 'actions', label: 'Actions', sticky: true, hide: shouldHideactions }, + { id: 'actions', label: 'Actions', sticky: true }, ], - [shouldHideactions], + [], ) const rows = useMemo( @@ -133,24 +130,32 @@ export const SpendingLimitsTable = ({ actions: { rawValue: '', sticky: true, - hide: shouldHideactions, content: ( - - onRemove(spendingLimit)} color="error" size="small"> - - - + + {(isOk) => ( + + onRemove(spendingLimit)} + color="error" + size="small" + disabled={!isOk} + > + + + + )} + ), }, }, } }), - [isLoading, shouldHideactions, spendingLimits], + [isLoading, spendingLimits], ) - return ( + return spendingLimits.length > 0 ? ( <> {open && setOpen(false)} steps={RemoveSpendingLimitSteps} initialData={[initialData]} />} - ) + ) : null } diff --git a/src/components/settings/SpendingLimits/index.tsx b/src/components/settings/SpendingLimits/index.tsx index 0888476e..d79d0fa9 100644 --- a/src/components/settings/SpendingLimits/index.tsx +++ b/src/components/settings/SpendingLimits/index.tsx @@ -4,16 +4,13 @@ import { SpendingLimitsTable } from '@/components/settings/SpendingLimits/Spendi import { useSelector } from 'react-redux' import { selectSpendingLimits, selectSpendingLimitsLoading } from '@/store/spendingLimitsSlice' import { NewSpendingLimit } from '@/components/settings/SpendingLimits/NewSpendingLimit' -import { useCurrentChain } from '@/hooks/useChains' -import { FEATURES, hasFeature } from '@/utils/chains' -import useIsGranted from '@/hooks/useIsGranted' +import { FEATURES } from '@/utils/chains' +import { useHasFeature } from '@/hooks/useChains' const SpendingLimits = () => { - const isGranted = useIsGranted() const spendingLimits = useSelector(selectSpendingLimits) const spendingLimitsLoading = useSelector(selectSpendingLimitsLoading) - const currentChain = useCurrentChain() - const isEnabled = currentChain && hasFeature(currentChain, FEATURES.SPENDING_LIMIT) + const isEnabled = useHasFeature(FEATURES.SPENDING_LIMIT) return ( @@ -28,11 +25,12 @@ const SpendingLimits = () => { {isEnabled ? ( - You can set rules for specific beneficiaries to access funds from this Safe without having to collect - all signatures. + You can set rules for specific beneficiaries to access funds from this Safe Account without having to + collect all signatures. - {isGranted && } + + {!spendingLimits.length && !spendingLimitsLoading && } ) : ( diff --git a/src/components/settings/TransactionGuards/RemoveGuard/index.tsx b/src/components/settings/TransactionGuards/RemoveGuard/index.tsx index dc13eb36..f54414cf 100644 --- a/src/components/settings/TransactionGuards/RemoveGuard/index.tsx +++ b/src/components/settings/TransactionGuards/RemoveGuard/index.tsx @@ -5,6 +5,7 @@ import DeleteIcon from '@/public/images/common/delete.svg' import TxModal from '@/components/tx/TxModal' import { ReviewRemoveGuard } from '@/components/settings/TransactionGuards/RemoveGuard/steps/ReviewRemoveGuard' import type { TxStepperProps } from '@/components/tx/TxStepper/useTxStepper' +import CheckWallet from '@/components/common/CheckWallet' export type RemoveGuardData = { address: string @@ -26,9 +27,14 @@ export const RemoveGuard = ({ address }: { address: string }) => { return ( <> - setOpen(true)} color="error" size="small"> - - + + {(isOk) => ( + setOpen(true)} color="error" size="small" disabled={!isOk}> + + + )} + + {open && setOpen(false)} steps={RemoveGuardSteps} initialData={[initialData]} />} ) diff --git a/src/components/settings/TransactionGuards/RemoveGuard/steps/ReviewRemoveGuard.tsx b/src/components/settings/TransactionGuards/RemoveGuard/steps/ReviewRemoveGuard.tsx index dd09dce6..11abaf6d 100644 --- a/src/components/settings/TransactionGuards/RemoveGuard/steps/ReviewRemoveGuard.tsx +++ b/src/components/settings/TransactionGuards/RemoveGuard/steps/ReviewRemoveGuard.tsx @@ -8,14 +8,12 @@ import EthHashInfo from '@/components/common/EthHashInfo' import type { RemoveGuardData } from '@/components/settings/TransactionGuards/RemoveGuard' import { Errors, logError } from '@/services/exceptions' import { trackEvent, SETTINGS_EVENTS } from '@/services/analytics' -import useTxSender from '@/hooks/useTxSender' +import { createRemoveGuardTx } from '@/services/tx/tx-sender' export const ReviewRemoveGuard = ({ data, onSubmit }: { data: RemoveGuardData; onSubmit: () => void }) => { - const { createRemoveGuardTx } = useTxSender() - const [safeTx, safeTxError] = useAsync(() => { return createRemoveGuardTx() - }, [createRemoveGuardTx]) + }, []) useEffect(() => { if (safeTxError) { diff --git a/src/components/settings/TransactionGuards/index.tsx b/src/components/settings/TransactionGuards/index.tsx index 1f0c8683..00e11b4f 100644 --- a/src/components/settings/TransactionGuards/index.tsx +++ b/src/components/settings/TransactionGuards/index.tsx @@ -2,12 +2,12 @@ import EthHashInfo from '@/components/common/EthHashInfo' import useSafeInfo from '@/hooks/useSafeInfo' import { Paper, Grid, Typography, Box } from '@mui/material' import { RemoveGuard } from './RemoveGuard' -import useIsGranted from '@/hooks/useIsGranted' import css from './styles.module.css' import ExternalLink from '@/components/common/ExternalLink' import { SAFE_FEATURES } from '@safe-global/safe-core-sdk-utils' import { hasSafeFeature } from '@/utils/safe-versions' +import { HelpCenterArticle } from '@/config/constants' const NoTransactionGuard = () => { return ( @@ -18,12 +18,10 @@ const NoTransactionGuard = () => { } const GuardDisplay = ({ guardAddress, chainId }: { guardAddress: string; chainId: string }) => { - const isGranted = useIsGranted() - return ( - {isGranted && } + ) } @@ -52,10 +50,7 @@ const TransactionGuards = () => { Transaction guards impose additional constraints that are checked prior to executing a Safe transaction. Transaction guards are potentially risky, so make sure to only use transaction guards from trusted sources. Learn more about transaction guards{' '} - - here - - . + here. {safe.guard ? ( diff --git a/src/components/settings/owner/AddOwnerDialog/DialogSteps/ChooseOwnerStep.tsx b/src/components/settings/owner/AddOwnerDialog/DialogSteps/ChooseOwnerStep.tsx index 8fd1452f..a89bb63a 100644 --- a/src/components/settings/owner/AddOwnerDialog/DialogSteps/ChooseOwnerStep.tsx +++ b/src/components/settings/owner/AddOwnerDialog/DialogSteps/ChooseOwnerStep.tsx @@ -60,8 +60,8 @@ export const ChooseOwnerStep = ({ {isReplace - ? 'Review the owner you want to replace in the active Safe, then specify the new owner you want to replace it with:' - : 'Add a new owner to the active Safe.'} + ? 'Review the owner you want to replace in the active Safe Account, then specify the new owner you want to replace it with:' + : 'Add a new owner to the active Safe Account.'} {removedOwner && ( diff --git a/src/components/settings/owner/AddOwnerDialog/DialogSteps/ReviewOwnerTxStep.tsx b/src/components/settings/owner/AddOwnerDialog/DialogSteps/ReviewOwnerTxStep.tsx index 5ad45149..835fc798 100644 --- a/src/components/settings/owner/AddOwnerDialog/DialogSteps/ReviewOwnerTxStep.tsx +++ b/src/components/settings/owner/AddOwnerDialog/DialogSteps/ReviewOwnerTxStep.tsx @@ -3,7 +3,6 @@ import useSafeInfo from '@/hooks/useSafeInfo' import { Box, Divider, Grid, Typography } from '@mui/material' import css from './styles.module.css' import type { ChangeOwnerData } from '@/components/settings/owner/AddOwnerDialog/DialogSteps/types' -import useTxSender from '@/hooks/useTxSender' import useAsync from '@/hooks/useAsync' import { upsertAddressBookEntry } from '@/store/addressBookSlice' import { useAppDispatch } from '@/store' @@ -13,9 +12,9 @@ import { sameAddress } from '@/utils/addresses' import useAddressBook from '@/hooks/useAddressBook' import React from 'react' import { trackEvent, SETTINGS_EVENTS } from '@/services/analytics' +import { createAddOwnerTx, createSwapOwnerTx } from '@/services/tx/tx-sender' export const ReviewOwnerTxStep = ({ data, onSubmit }: { data: ChangeOwnerData; onSubmit: () => void }) => { - const { createSwapOwnerTx, createAddOwnerTx } = useTxSender() const { safe, safeAddress } = useSafeInfo() const { chainId } = safe const dispatch = useAppDispatch() @@ -34,7 +33,7 @@ export const ReviewOwnerTxStep = ({ data, onSubmit }: { data: ChangeOwnerData; o threshold, }) } - }, [removedOwner, newOwner, createSwapOwnerTx, createAddOwnerTx]) + }, [removedOwner, newOwner]) const isReplace = Boolean(removedOwner) @@ -68,7 +67,7 @@ export const ReviewOwnerTxStep = ({ data, onSubmit }: { data: ChangeOwnerData; o Details - Name of the Safe: + Name of the Safe Account: {addressBook[safeAddress] || 'No name'} @@ -86,7 +85,7 @@ export const ReviewOwnerTxStep = ({ data, onSubmit }: { data: ChangeOwnerData; o borderLeft={({ palette }) => [undefined, undefined, `1px solid ${palette.border.light}`]} borderTop={({ palette }) => [`1px solid ${palette.border.light}`, undefined, 'none']} > - {safe.owners.length} Safe owner(s) + {safe.owners.length} Safe Account owner(s) {safe.owners diff --git a/src/components/settings/owner/AddOwnerDialog/DialogSteps/styles.module.css b/src/components/settings/owner/AddOwnerDialog/DialogSteps/styles.module.css index d3772b38..7ec06ea4 100644 --- a/src/components/settings/owner/AddOwnerDialog/DialogSteps/styles.module.css +++ b/src/components/settings/owner/AddOwnerDialog/DialogSteps/styles.module.css @@ -5,3 +5,11 @@ flex-direction: column; padding: var(--space-1); } + +.overline { + font-size: 11px; + line-height: 14px; + letter-spacing: 1px; + align-self: center; + text-transform: uppercase; +} diff --git a/src/components/settings/owner/AddOwnerDialog/index.tsx b/src/components/settings/owner/AddOwnerDialog/index.tsx index 1aa5bb70..668c3e68 100644 --- a/src/components/settings/owner/AddOwnerDialog/index.tsx +++ b/src/components/settings/owner/AddOwnerDialog/index.tsx @@ -12,6 +12,7 @@ import type { TxStepperProps } from '@/components/tx/TxStepper/useTxStepper' import Box from '@mui/material/Box' import Track from '@/components/common/Track' import { SETTINGS_EVENTS } from '@/services/analytics/events/settings' +import CheckWallet from '@/components/common/CheckWallet' const AddOwnerSteps: TxStepperProps['steps'] = [ { @@ -39,17 +40,21 @@ export const AddOwnerDialog = () => { return ( -
- - - -
+ + {(isOk) => ( + + + + )} + + {open && }
) diff --git a/src/components/settings/owner/ChangeThresholdDialog/index.tsx b/src/components/settings/owner/ChangeThresholdDialog/index.tsx index 2c2d1530..84d11659 100644 --- a/src/components/settings/owner/ChangeThresholdDialog/index.tsx +++ b/src/components/settings/owner/ChangeThresholdDialog/index.tsx @@ -5,7 +5,6 @@ import { useState } from 'react' import TxModal from '@/components/tx/TxModal' import useSafeInfo from '@/hooks/useSafeInfo' -import useTxSender from '@/hooks/useTxSender' import useAsync from '@/hooks/useAsync' import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' @@ -13,6 +12,8 @@ import type { TxStepperProps } from '@/components/tx/TxStepper/useTxStepper' import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' import Track from '@/components/common/Track' import { trackEvent, SETTINGS_EVENTS } from '@/services/analytics' +import { createUpdateThresholdTx } from '@/services/tx/tx-sender' +import CheckWallet from '@/components/common/CheckWallet' interface ChangeThresholdData { threshold: number @@ -36,13 +37,16 @@ export const ChangeThresholdDialog = () => { return ( -
- - - -
+ + {(isOk) => ( + + + + )} + + {open && }
) @@ -50,7 +54,6 @@ export const ChangeThresholdDialog = () => { const ChangeThresholdStep = ({ data, onSubmit }: { data: ChangeThresholdData; onSubmit: () => void }) => { const { safe } = useSafeInfo() - const { createUpdateThresholdTx } = useTxSender() const [selectedThreshold, setSelectedThreshold] = useState(safe.threshold) const [isChanged, setChanged] = useState(false) const isSameThreshold = selectedThreshold === safe.threshold @@ -65,9 +68,9 @@ const ChangeThresholdStep = ({ data, onSubmit }: { data: ChangeThresholdData; on if (!selectedThreshold) return return createUpdateThresholdTx(selectedThreshold) - }, [selectedThreshold, createUpdateThresholdTx]) + }, [selectedThreshold]) - const onChangeTheshold = () => { + const onChangeThreshold = () => { trackEvent({ ...SETTINGS_EVENTS.SETUP.OWNERS, label: safe.owners.length }) trackEvent({ ...SETTINGS_EVENTS.SETUP.THRESHOLD, label: selectedThreshold }) @@ -113,7 +116,7 @@ const ChangeThresholdStep = ({ data, onSubmit }: { data: ChangeThresholdData; on diff --git a/src/components/settings/owner/OwnerList/index.tsx b/src/components/settings/owner/OwnerList/index.tsx index 85b70808..512b084e 100644 --- a/src/components/settings/owner/OwnerList/index.tsx +++ b/src/components/settings/owner/OwnerList/index.tsx @@ -16,7 +16,7 @@ const headCells = [ { id: 'actions', label: '', sticky: true }, ] -export const OwnerList = ({ isGranted }: { isGranted: boolean }) => { +export const OwnerList = () => { const addressBook = useAddressBook() const { safe } = useSafeInfo() @@ -36,23 +36,23 @@ export const OwnerList = ({ isGranted }: { isGranted: boolean }) => { sticky: true, content: (
- {isGranted && } + - {isGranted && } +
), }, }, } }) - }, [safe, addressBook, isGranted]) + }, [safe, addressBook]) return ( - Manage Safe owners + Manage Safe Account owners @@ -63,7 +63,7 @@ export const OwnerList = ({ isGranted }: { isGranted: boolean }) => {
- {isGranted && } +
diff --git a/src/components/settings/owner/RemoveOwnerDialog/DialogSteps/ReviewRemoveOwnerTxStep.tsx b/src/components/settings/owner/RemoveOwnerDialog/DialogSteps/ReviewRemoveOwnerTxStep.tsx index ce14139b..b152ff6f 100644 --- a/src/components/settings/owner/RemoveOwnerDialog/DialogSteps/ReviewRemoveOwnerTxStep.tsx +++ b/src/components/settings/owner/RemoveOwnerDialog/DialogSteps/ReviewRemoveOwnerTxStep.tsx @@ -2,7 +2,6 @@ import EthHashInfo from '@/components/common/EthHashInfo' import useSafeInfo from '@/hooks/useSafeInfo' import { Box, Divider, Grid, Typography } from '@mui/material' import css from './styles.module.css' -import useTxSender from '@/hooks/useTxSender' import useAsync from '@/hooks/useAsync' import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' @@ -11,16 +10,16 @@ import useAddressBook from '@/hooks/useAddressBook' import type { RemoveOwnerData } from '..' import React from 'react' import { trackEvent, SETTINGS_EVENTS } from '@/services/analytics' +import { createRemoveOwnerTx } from '@/services/tx/tx-sender' export const ReviewRemoveOwnerTxStep = ({ data, onSubmit }: { data: RemoveOwnerData; onSubmit: () => void }) => { - const { createRemoveOwnerTx } = useTxSender() const { safe, safeAddress } = useSafeInfo() const addressBook = useAddressBook() const { removedOwner, threshold } = data const [safeTx, safeTxError] = useAsync(async () => { return createRemoveOwnerTx({ ownerAddress: removedOwner.address, threshold }) - }, [removedOwner.address, threshold, createRemoveOwnerTx]) + }, [removedOwner.address, threshold]) const newOwnerLength = safe.owners.length - 1 @@ -44,7 +43,7 @@ export const ReviewRemoveOwnerTxStep = ({ data, onSubmit }: { data: RemoveOwnerD Details - Name of the Safe: + Name of the Safe Account: {addressBook[safeAddress] || 'No name'} @@ -62,7 +61,7 @@ export const ReviewRemoveOwnerTxStep = ({ data, onSubmit }: { data: RemoveOwnerD borderLeft={({ palette }) => [undefined, undefined, `1px solid ${palette.border.light}`]} borderTop={({ palette }) => [`1px solid ${palette.border.light}`, undefined, 'none']} > - {newOwnerLength} Safe owner(s) + {newOwnerLength} Safe Account owner(s) {safe.owners .filter((owner) => !sameAddress(owner.value, removedOwner.address)) diff --git a/src/components/settings/owner/RemoveOwnerDialog/DialogSteps/ReviewSelectedOwnerStep.tsx b/src/components/settings/owner/RemoveOwnerDialog/DialogSteps/ReviewSelectedOwnerStep.tsx index 371e211c..2fed2df8 100644 --- a/src/components/settings/owner/RemoveOwnerDialog/DialogSteps/ReviewSelectedOwnerStep.tsx +++ b/src/components/settings/owner/RemoveOwnerDialog/DialogSteps/ReviewSelectedOwnerStep.tsx @@ -12,7 +12,7 @@ export const ReviewSelectedOwnerStep = ({ return (
onSubmit(data)}> - Review the owner you want to remove from the active Safe: + Review the owner you want to remove from the active Safe Account: { return (
- - - setOpen(true)} size="small"> - - - - + + {(isOk) => ( + + + setOpen(true)} size="small" disabled={!isOk}> + + + + + )} + + {open && }
) diff --git a/src/components/settings/owner/ReplaceOwnerDialog/index.tsx b/src/components/settings/owner/ReplaceOwnerDialog/index.tsx index 6f2814c4..1cda1147 100644 --- a/src/components/settings/owner/ReplaceOwnerDialog/index.tsx +++ b/src/components/settings/owner/ReplaceOwnerDialog/index.tsx @@ -10,6 +10,7 @@ import type { TxStepperProps } from '@/components/tx/TxStepper/useTxStepper' import Track from '@/components/common/Track' import { SETTINGS_EVENTS } from '@/services/analytics/events/settings' import ReplaceOwnerIcon from '@/public/images/settings/setup/replace-owner.svg' +import CheckWallet from '@/components/common/CheckWallet' const ReplaceOwnerSteps: TxStepperProps['steps'] = [ { @@ -36,13 +37,18 @@ export const ReplaceOwnerDialog = ({ address }: { address: string }) => { return (
- - - setOpen(true)} size="small"> - - - - + + {(isOk) => ( + + + setOpen(true)} size="small" disabled={!isOk}> + + + + + )} + + {open && }
) diff --git a/src/components/sidebar/DebugToggle/index.tsx b/src/components/sidebar/DebugToggle/index.tsx index 27a2a201..eb123002 100644 --- a/src/components/sidebar/DebugToggle/index.tsx +++ b/src/components/sidebar/DebugToggle/index.tsx @@ -16,7 +16,7 @@ const DebugToggle = (): ReactElement => { const [isProdGateway = false, setIsProdGateway] = useLocalStorage(LS_KEY) - const onToggle = (event: ChangeEvent) => { + const onToggleGateway = (event: ChangeEvent) => { setIsProdGateway(event.target.checked) setTimeout(() => { @@ -30,7 +30,7 @@ const DebugToggle = (): ReactElement => { control={ dispatch(setDarkMode(checked))} />} label="Dark mode" /> - } label="Use prod CGW" /> + } label="Use prod CGW" /> ) } diff --git a/src/components/sidebar/NewTxButton/index.tsx b/src/components/sidebar/NewTxButton/index.tsx index 7a9c7150..eb7659d7 100644 --- a/src/components/sidebar/NewTxButton/index.tsx +++ b/src/components/sidebar/NewTxButton/index.tsx @@ -1,24 +1,14 @@ import { Suspense, useState, type ReactElement } from 'react' import dynamic from 'next/dynamic' import Button from '@mui/material/Button' -import useIsSafeOwner from '@/hooks/useIsSafeOwner' -import useWallet from '@/hooks/wallets/useWallet' -import useIsWrongChain from '@/hooks/useIsWrongChain' import css from './styles.module.css' import { trackEvent, OVERVIEW_EVENTS } from '@/services/analytics' -import ChainSwitcher from '@/components/common/ChainSwitcher' -import useIsOnlySpendingLimitBeneficiary from '@/hooks/useIsOnlySpendingLimitBeneficiary' +import CheckWallet from '@/components/common/CheckWallet' const NewTxModal = dynamic(() => import('@/components/tx/modals/NewTxModal')) const NewTxButton = (): ReactElement => { const [txOpen, setTxOpen] = useState(false) - const wallet = useWallet() - const isSafeOwner = useIsSafeOwner() - const isOnlySpendingLimitBeneficiary = useIsOnlySpendingLimitBeneficiary() - const isWrongChain = useIsWrongChain() - - const canCreateTx = isSafeOwner || isOnlySpendingLimitBeneficiary const onClick = () => { setTxOpen(true) @@ -26,21 +16,23 @@ const NewTxButton = (): ReactElement => { trackEvent(OVERVIEW_EVENTS.NEW_TRANSACTION) } - if (isWrongChain) return - return ( <> - + + {(isOk) => ( + + )} + {txOpen && ( diff --git a/src/components/sidebar/OwnedSafes/index.tsx b/src/components/sidebar/OwnedSafes/index.tsx deleted file mode 100644 index 90ea524c..00000000 --- a/src/components/sidebar/OwnedSafes/index.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { useCurrentChain } from '@/hooks/useChains' -import useOwnedSafes from '@/hooks/useOwnedSafes' -import { List, Typography } from '@mui/material' -import { type ReactElement } from 'react' -import SafeListItem from '../SafeListItem' - -const OwnedSafes = (): ReactElement | null => { - const chain = useCurrentChain() - const allOwnedSafes = useOwnedSafes() - const ownedSafesOnChain = chain ? allOwnedSafes[chain.chainId] : undefined - - if (!chain || !ownedSafesOnChain?.length) { - return null - } - - return ( - <> - - Safes owned on {chain.chainName} - - - - {ownedSafesOnChain?.map((address) => ( - void null} - shouldScrollToSafe={false} - noActions - /> - ))} - - - ) -} - -export default OwnedSafes diff --git a/src/components/sidebar/PendingActions/index.tsx b/src/components/sidebar/PendingActions/index.tsx index b574b18d..697460f7 100644 --- a/src/components/sidebar/PendingActions/index.tsx +++ b/src/components/sidebar/PendingActions/index.tsx @@ -52,7 +52,7 @@ const PendingActionButtons = ({ borderBottomRightRadius: ({ shape }) => shape.borderRadius, }} > - + {totalToSign} diff --git a/src/components/sidebar/QrCodeButton/QrModal.tsx b/src/components/sidebar/QrCodeButton/QrModal.tsx index 0a9431c2..50f5b15e 100644 --- a/src/components/sidebar/QrCodeButton/QrModal.tsx +++ b/src/components/sidebar/QrCodeButton/QrModal.tsx @@ -22,12 +22,12 @@ const QrModal = ({ onClose }: { onClose: () => void }): ReactElement => { - {chainName} network — only send {chainName} assets to this Safe. + {chainName} network — only send {chainName} assets to this Safe Account. - This is the address of your Safe. Deposit funds by scanning the QR code or copying the address below. Only - send {nativeToken} and tokens (e.g. ERC20, ERC721) to this address. + This is the address of your Safe Account. Deposit funds by scanning the QR code or copying the address below. + Only send {nativeToken} and tokens (e.g. ERC20, ERC721) to this address. diff --git a/src/components/sidebar/SafeList/index.tsx b/src/components/sidebar/SafeList/index.tsx index 06d68fb3..9e7a7acd 100644 --- a/src/components/sidebar/SafeList/index.tsx +++ b/src/components/sidebar/SafeList/index.tsx @@ -1,4 +1,4 @@ -import React, { Fragment, useState, type ReactElement } from 'react' +import React, { Fragment, useState, type ReactElement, useCallback } from 'react' import { useRouter } from 'next/router' import Link from 'next/link' import List from '@mui/material/List' @@ -11,6 +11,7 @@ import IconButton from '@mui/material/IconButton' import SvgIcon from '@mui/material/SvgIcon' import Box from '@mui/material/Box' import { Link as MuiLink } from '@mui/material' +import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' import AddIcon from '@/public/images/common/add.svg' import useChains from '@/hooks/useChains' @@ -29,6 +30,9 @@ import useSafeInfo from '@/hooks/useSafeInfo' import Track from '@/components/common/Track' import { OVERVIEW_EVENTS } from '@/services/analytics/events/overview' import LoadingIcon from '@/public/images/common/loading.svg' +import useWallet from '@/hooks/wallets/useWallet' +import useConnectWallet from '@/components/common/ConnectWallet/useConnectWallet' +import KeyholeIcon from '@/components/common/icons/KeyholeIcon' export const _shouldExpandSafeList = ({ isCurrentChain, @@ -57,7 +61,8 @@ export const _shouldExpandSafeList = ({ } const MAX_EXPANDED_SAFES = 3 -const NO_SAFE_MESSAGE = 'Create a new safe or add' +const NO_WALLET_MESSAGE = 'Connect a wallet to view your Safe Accounts\n or to create a new one' +const NO_SAFE_MESSAGE = 'Create a new Safe Account or add' const SafeList = ({ closeDrawer }: { closeDrawer?: () => void }): ReactElement => { const router = useRouter() @@ -66,20 +71,39 @@ const SafeList = ({ closeDrawer }: { closeDrawer?: () => void }): ReactElement = const { configs } = useChains() const ownedSafes = useOwnedSafes() const addedSafes = useAppSelector(selectAllAddedSafes) + const wallet = useWallet() + const handleConnect = useConnectWallet() const [open, setOpen] = useState>({}) const toggleOpen = (chainId: string, open: boolean) => { setOpen((prev) => ({ ...prev, [chainId]: open })) } + const hasWallet = !!wallet const hasNoSafes = Object.keys(ownedSafes).length === 0 && Object.keys(addedSafes).length === 0 const isWelcomePage = router.pathname === AppRoutes.welcome + const isSingleTxPage = router.pathname === AppRoutes.transactions.tx + + /** + * Navigate to the dashboard when selecting a safe on the welcome page, + * navigate to the history when selecting a safe on a single tx page, + * otherwise keep the current route + */ + const getHref = useCallback( + (chain: ChainInfo, address: string) => { + return { + pathname: isWelcomePage ? AppRoutes.home : isSingleTxPage ? AppRoutes.transactions.history : router.pathname, + query: { ...router.query, safe: `${chain.shortName}:${address}` }, + } + }, + [isWelcomePage, isSingleTxPage, router.pathname, router.query], + ) return (
- My Safes + My Safe Accounts {!isWelcomePage && ( @@ -100,18 +124,37 @@ const SafeList = ({ closeDrawer }: { closeDrawer?: () => void }): ReactElement =
{hasNoSafes && ( - - - - {!isWelcomePage ? ( - - {NO_SAFE_MESSAGE} - - ) : ( - <>{NO_SAFE_MESSAGE} - )}{' '} - an existing one - + + {hasWallet ? ( + <> + + + + {!isWelcomePage ? ( + + {NO_SAFE_MESSAGE} + + ) : ( + <>{NO_SAFE_MESSAGE} + )}{' '} + an existing one + + + ) : ( + + + + + + + {NO_WALLET_MESSAGE} + + + + + )} )} @@ -157,18 +200,22 @@ const SafeList = ({ closeDrawer }: { closeDrawer?: () => void }): ReactElement = {/* Added Safes */} - {addedSafeEntriesOnChain.map(([address, { threshold, owners }]) => ( - - ))} + {addedSafeEntriesOnChain.map(([address, { threshold, owners }]) => { + const href = getHref(chain, address) + return ( + + ) + })} {isCurrentChain && safeAddress && @@ -180,6 +227,7 @@ const SafeList = ({ closeDrawer }: { closeDrawer?: () => void }): ReactElement = owners={safe.owners.length} chainId={safe.chainId} closeDrawer={closeDrawer} + href={{ pathname: router.pathname, query: router.query }} shouldScrollToSafe /> )} @@ -190,22 +238,27 @@ const SafeList = ({ closeDrawer }: { closeDrawer?: () => void }): ReactElement = <>
toggleOpen(chain.chainId, !isOpen)} className={css.ownedLabelWrapper}> - Safes owned on {chain.chainName} ({ownedSafesOnChain.length}) + Safe Accounts owned on {chain.chainName} ({ownedSafesOnChain.length}) {isOpen ? : }
- {ownedSafesOnChain.map((address) => ( - - ))} + {ownedSafesOnChain.map((address) => { + const href = getHref(chain, address) + + return ( + + ) + })} diff --git a/src/components/sidebar/SafeListItem/index.tsx b/src/components/sidebar/SafeListItem/index.tsx index 09e37c83..c97c8493 100644 --- a/src/components/sidebar/SafeListItem/index.tsx +++ b/src/components/sidebar/SafeListItem/index.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, type ReactElement } from 'react' +import { useEffect, useRef, type ReactElement, type ComponentProps } from 'react' import ListItemButton from '@mui/material/ListItemButton' import ListItemText from '@mui/material/ListItemText' import ListItem from '@mui/material/ListItem' @@ -28,6 +28,7 @@ const SafeListItem = ({ chainId, closeDrawer, shouldScrollToSafe, + href, noActions = false, isAdded = false, ...rest @@ -35,6 +36,7 @@ const SafeListItem = ({ address: string chainId: string shouldScrollToSafe: boolean + href: ComponentProps['href'] closeDrawer?: () => void threshold?: string | number owners?: string | number @@ -80,7 +82,7 @@ const SafeListItem = ({ ) } > - + - Add Safe + Add ) diff --git a/src/components/sidebar/SafeListRemoveDialog/index.tsx b/src/components/sidebar/SafeListRemoveDialog/index.tsx index a3d7061f..0125509d 100644 --- a/src/components/sidebar/SafeListRemoveDialog/index.tsx +++ b/src/components/sidebar/SafeListRemoveDialog/index.tsx @@ -32,7 +32,7 @@ const SafeListRemoveDialog = ({ - Are you sure you want to remove {safe} from your list of added Safes? + Are you sure you want to remove {safe} from your list of added Safe Accounts? diff --git a/src/components/sidebar/SidebarFooter/index.tsx b/src/components/sidebar/SidebarFooter/index.tsx index cd8625d2..a99b9b7c 100644 --- a/src/components/sidebar/SidebarFooter/index.tsx +++ b/src/components/sidebar/SidebarFooter/index.tsx @@ -16,12 +16,11 @@ import { openCookieBanner } from '@/store/popupSlice' import SuggestionIcon from '@/public/images/sidebar/lightbulb_icon.svg' import { ListItem } from '@mui/material' import DebugToggle from '../DebugToggle' -import { IS_PRODUCTION } from '@/config/constants' +import { /* HELP_CENTER_URL, */ IS_PRODUCTION } from '@/config/constants' import Track from '@/components/common/Track' import { OVERVIEW_EVENTS } from '@/services/analytics/events/overview' import { useCurrentChain } from '@/hooks/useChains' -// const WHATS_NEW_PATH = 'https://help.safe.global/en/' const SUGGESTION_PATH = 'https://docs.google.com/forms/d/e/1FAIpQLSfojsADYCiWq9AqbLqsUTzCDSpA8FMgdAQp0Pyl0BOeurlq9A/viewform?usp=sf_link' @@ -66,7 +65,7 @@ const SidebarFooter = (): ReactElement => { - + diff --git a/src/components/sidebar/SidebarNavigation/config.tsx b/src/components/sidebar/SidebarNavigation/config.tsx index cba619dc..dc7df069 100644 --- a/src/components/sidebar/SidebarNavigation/config.tsx +++ b/src/components/sidebar/SidebarNavigation/config.tsx @@ -93,7 +93,7 @@ export const settingsNavItems = [ href: AppRoutes.settings.spendingLimits, }, { - label: 'Safe Apps permissions', + label: 'Safe Apps', href: AppRoutes.settings.safeApps.index, }, { @@ -106,6 +106,25 @@ export const settingsNavItems = [ }, ] +export const generalSettingsNavItems = [ + { + label: 'Cookies', + href: AppRoutes.settings.cookies, + }, + { + label: 'Appearance', + href: AppRoutes.settings.appearance, + }, + { + label: 'Data', + href: AppRoutes.settings.data, + }, + { + label: 'Environment variables', + href: AppRoutes.settings.environmentVariables, + }, +] + export const safeAppsNavItems = [ { label: 'All apps', diff --git a/src/components/terms/index.tsx b/src/components/terms/index.tsx index 68befbb3..99f34ab6 100644 --- a/src/components/terms/index.tsx +++ b/src/components/terms/index.tsx @@ -2,6 +2,7 @@ import { Typography } from '@mui/material' import Link from 'next/link' import MUILink from '@mui/material/Link' import { AppRoutes } from '@/config/routes' +import { DISCORD_URL, HELP_CENTER_URL, TWITTER_URL } from '@/config/constants' const SafeTerms = () => { return ( @@ -9,7 +10,7 @@ const SafeTerms = () => { Terms and Conditions -

Last updated: March, 2023

+

Last updated: May, 2023

1. What is the scope of the Terms?

  1. @@ -79,15 +80,15 @@ const SafeTerms = () => {

3. What are the Services offered?

- Our services (“Services”) primarily consist of enabling users to create their Safes and ongoing - interaction with it on the Blockchain. + Our services (“Services”) primarily consist of enabling users to create their Safe Accounts and + ongoing interaction with it on the Blockchain.

    -
  1. “Safe”
  2. +
  3. “Safe Account”

- The Safe (“Safe”) is a modular, self-custodial (i.e. not supervised by us) smart contract-based - wallet not provided by CC. Safe is{' '} + A Safe Account is a modular, self-custodial (i.e. not supervised by us) smart contract-based wallet not provided + by CC. Safe Accounts are{' '} open-source @@ -102,29 +103,30 @@ const SafeTerms = () => { Transaction can be configured in code.{' '}

- Owners need to connect a signing wallet with the Safe. Safe is compatible inter alia with standard private - key Wallets such as hardware wallets, browser extension wallets and mobile wallets that support WalletConnect. + Owners need to connect a signing wallet with a Safe Account. Safe Accounts are compatible inter alia with + standard private key Wallets such as hardware wallets, browser extension wallets and mobile wallets that support + WalletConnect.

    -
  1. “Safe App”
  2. +
  3. “Safe App”

- You may access the Safe using the Safe web app, mobile app for iOS and android, or the browser - extension (each a “Safe App”). The Safe App may be used to manage your personal - digital assets on Ethereum and other common EVM chains when you connect the Safe with third-party services - (as defined below). The Safe App provides certain features that may be amended from time to time.{' '} + You may access Safe Accounts using the {'Safe{Wallet}'} web app, mobile app for iOS and android, or the browser + extension (each a “Safe App”). The Safe App may be used to manage your personal digital assets + on Ethereum and other common EVM chains when you connect a Safe Account with third-party services (as + defined below). The Safe App provides certain features that may be amended from time to time.{' '}

    -
  1. “Third-Party Apps”
  2. +
  3. “Third-Party Safe Apps”

- The Safe App allows you to connect the Safe to third-party decentralized applications - (“Third-Party Apps”) and use third-party services such as from the decentralized finance - sector, DAO Tools or services related to NFTs (“Third-Party Services"). The - Third-Party Apps are integrated in the user interface of the Safe App via inline framing. The provider - of the Third-Party App and related Third-Party Service is responsible for the operation of the service and - the correctness, completeness and actuality of any information provided therein. We make a pre-selection of - Third-Party Apps that we show in the Safe App. However, we only perform a rough triage in advance for + The Safe App allows you to connect Safe Accounts to third-party decentralized applications + (“Third-Party Safe Apps”) and use third-party services such as from the decentralized + finance sector, DAO Tools or services related to NFTs (“Third-Party Services"). The + Third-Party Safe Apps are integrated in the user interface of the Safe App via inline framing. The provider + of the Third-Party Safe App and related Third-Party Service is responsible for the operation of the service + and the correctness, completeness and actuality of any information provided therein. We make a pre-selection of + Third-Party Safe Apps that we show in the Safe App. However, we only perform a rough triage in advance for obvious problems and functionality in terms of loading time and resolution capability of the transactions. Accordingly, in the event of any (technical) issues concerning the Third-Party Services, the user must only contact the respective service provider directly. The terms of service, if any, shall be governed by the @@ -141,13 +143,13 @@ const SafeTerms = () => {

  • coverage underwritten by any regulatory agency’s compensation scheme;
  • - custody of your Recovery Phrase, Private Keys, Tokens or the ability to remove or freeze your Tokens, i.e. - Safe is a self-custodial wallet; + custody of your Recovery Phrase, Private Keys, Tokens or the ability to remove or freeze your Tokens, i.e. a + Safe Account is a self-custodial wallet;
  • the storage or transmission of fiat currencies;
  • back-up services to recover your Recovery Phrase or Private Keys, for whose safekeeping you are solely - responsible; CC has no means to recover your access to your Tokens, when you lose access to your Safe; + responsible; CC has no means to recover your access to your Tokens, when you lose access to your Safe Account;
  • any form of legal, financial, investment, accounting, tax or other professional advice regarding Transactions @@ -161,10 +163,10 @@ const SafeTerms = () => {

    5. What do you need to know about Third-Party Services?

    1. - We provide you the possibility to interact with your Safe through Third-Party Services. Any activities - you engage in with, or services you receive from a third party is between you and that third party directly. - The conditions of service provisions, if any, shall be governed by the applicable contractual provisions - between you and the respective provider of the Third-Party Service.{' '} + We provide you the possibility to interact with your Safe Account through Third-Party Services. Any + activities you engage in with, or services you receive from a third party is between you and that third party + directly. The conditions of service provisions, if any, shall be governed by the applicable contractual + provisions between you and the respective provider of the Third-Party Service.{' '}
    2. The Services rely in part on third-party and open-source software, including the Blockchain, and the continued @@ -177,7 +179,7 @@ const SafeTerms = () => {